Merge pull request #13705 from netbox-community/develop

Release v3.6.1
This commit is contained in:
Jeremy Stretch 2023-09-06 14:23:36 -04:00 committed by GitHub
commit 99ab054ea0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 428 additions and 179 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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."))

View File

@ -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):

View File

@ -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.")

View File

@ -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):

View File

@ -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)

View File

@ -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']
#

View File

@ -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

View File

@ -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',

View File

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

View File

@ -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
),
),
),

View File

@ -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 = {

View File

@ -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 %}

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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):

View File

@ -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()
#

View File

@ -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'])

View File

@ -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 {

View File

@ -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:

View File

@ -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
)

View File

@ -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