mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
commit
99ab054ea0
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.0
|
placeholder: v3.6.1
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.0
|
placeholder: v3.6.1
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -23,17 +23,3 @@ If designated, this platform will be available for use only to devices assigned
|
|||||||
### Configuration Template
|
### Configuration Template
|
||||||
|
|
||||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
|
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.
|
|
||||||
|
@ -64,12 +64,15 @@ item1 = PluginMenuItem(
|
|||||||
|
|
||||||
A `PluginMenuItem` has the following attributes:
|
A `PluginMenuItem` has the following attributes:
|
||||||
|
|
||||||
| Attribute | Required | Description |
|
| Attribute | Required | Description |
|
||||||
|---------------|----------|------------------------------------------------------|
|
|---------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||||
| `link_text` | Yes | The text presented to the user |
|
| `link_text` | Yes | The text presented to the user |
|
||||||
| `permissions` | - | A list of permissions required to display this link |
|
| `permissions` | - | A list of permissions required to display this link |
|
||||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
| `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
|
## Menu Buttons
|
||||||
|
|
||||||
|
@ -1,10 +1,38 @@
|
|||||||
# NetBox v3.6
|
# 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)
|
## v3.6.0 (2023-08-30)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
|
* 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 `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 `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.
|
* 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
|
* [#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
|
* [#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
|
* [#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
|
* [#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
|
* [#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
|
* [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
|
||||||
|
|
||||||
|
@ -1,4 +1,15 @@
|
|||||||
from django.apps import AppConfig
|
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):
|
class CoreConfig(AppConfig):
|
||||||
|
@ -1,18 +1,6 @@
|
|||||||
# noinspection PyUnresolvedReferences
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
from django.core.management.commands.makemigrations import Command as _Command
|
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):
|
class Command(_Command):
|
||||||
|
@ -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
|
|
@ -316,7 +316,7 @@ class DataFile(models.Model):
|
|||||||
if not self.data:
|
if not self.data:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return bytes(self.data, 'utf-8')
|
return self.data.decode('utf-8')
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ from django.contrib import messages
|
|||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
|
||||||
from extras.models import ConfigRevision
|
from extras.models import ConfigRevision
|
||||||
|
from netbox.config import get_config
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from netbox.views.generic.base import BaseObjectView
|
from netbox.views.generic.base import BaseObjectView
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
@ -152,4 +153,9 @@ class ConfigView(generic.ObjectView):
|
|||||||
queryset = ConfigRevision.objects.all()
|
queryset = ConfigRevision.objects.all()
|
||||||
|
|
||||||
def get_object(self, **kwargs):
|
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
|
||||||
|
)
|
||||||
|
@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
|
||||||
method = serializers.JSONField()
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Device components
|
# Device components
|
||||||
#
|
#
|
||||||
|
@ -2033,7 +2033,6 @@ class DeviceRenderConfigView(generic.ObjectView):
|
|||||||
template_name = 'dcim/device/render_config.html'
|
template_name = 'dcim/device/render_config.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Render Config'),
|
label=_('Render Config'),
|
||||||
permission='extras.view_configtemplate',
|
|
||||||
weight=2100
|
weight=2100
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2185,6 +2184,15 @@ class ConsolePortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConsolePortFilterSet
|
filterset = filtersets.ConsolePortFilterSet
|
||||||
filterset_form = forms.ConsolePortFilterForm
|
filterset_form = forms.ConsolePortFilterForm
|
||||||
table = tables.ConsolePortTable
|
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)
|
@register_model_view(ConsolePort)
|
||||||
@ -2248,6 +2256,15 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConsoleServerPortFilterSet
|
filterset = filtersets.ConsoleServerPortFilterSet
|
||||||
filterset_form = forms.ConsoleServerPortFilterForm
|
filterset_form = forms.ConsoleServerPortFilterForm
|
||||||
table = tables.ConsoleServerPortTable
|
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)
|
@register_model_view(ConsoleServerPort)
|
||||||
@ -2311,6 +2328,15 @@ class PowerPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.PowerPortFilterSet
|
filterset = filtersets.PowerPortFilterSet
|
||||||
filterset_form = forms.PowerPortFilterForm
|
filterset_form = forms.PowerPortFilterForm
|
||||||
table = tables.PowerPortTable
|
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)
|
@register_model_view(PowerPort)
|
||||||
@ -2374,6 +2400,15 @@ class PowerOutletListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.PowerOutletFilterSet
|
filterset = filtersets.PowerOutletFilterSet
|
||||||
filterset_form = forms.PowerOutletFilterForm
|
filterset_form = forms.PowerOutletFilterForm
|
||||||
table = tables.PowerOutletTable
|
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)
|
@register_model_view(PowerOutlet)
|
||||||
@ -2437,6 +2472,15 @@ class InterfaceListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.InterfaceFilterSet
|
filterset = filtersets.InterfaceFilterSet
|
||||||
filterset_form = forms.InterfaceFilterForm
|
filterset_form = forms.InterfaceFilterForm
|
||||||
table = tables.InterfaceTable
|
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)
|
@register_model_view(Interface)
|
||||||
@ -2548,6 +2592,15 @@ class FrontPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.FrontPortFilterSet
|
filterset = filtersets.FrontPortFilterSet
|
||||||
filterset_form = forms.FrontPortFilterForm
|
filterset_form = forms.FrontPortFilterForm
|
||||||
table = tables.FrontPortTable
|
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)
|
@register_model_view(FrontPort)
|
||||||
@ -2611,6 +2664,15 @@ class RearPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.RearPortFilterSet
|
filterset = filtersets.RearPortFilterSet
|
||||||
filterset_form = forms.RearPortFilterForm
|
filterset_form = forms.RearPortFilterForm
|
||||||
table = tables.RearPortTable
|
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)
|
@register_model_view(RearPort)
|
||||||
@ -2674,6 +2736,15 @@ class ModuleBayListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ModuleBayFilterSet
|
filterset = filtersets.ModuleBayFilterSet
|
||||||
filterset_form = forms.ModuleBayFilterForm
|
filterset_form = forms.ModuleBayFilterForm
|
||||||
table = tables.ModuleBayTable
|
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)
|
@register_model_view(ModuleBay)
|
||||||
@ -2729,6 +2800,15 @@ class DeviceBayListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.DeviceBayFilterSet
|
filterset = filtersets.DeviceBayFilterSet
|
||||||
filterset_form = forms.DeviceBayFilterForm
|
filterset_form = forms.DeviceBayFilterForm
|
||||||
table = tables.DeviceBayTable
|
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)
|
@register_model_view(DeviceBay)
|
||||||
@ -2853,6 +2933,15 @@ class InventoryItemListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.InventoryItemFilterSet
|
filterset = filtersets.InventoryItemFilterSet
|
||||||
filterset_form = forms.InventoryItemFilterForm
|
filterset_form = forms.InventoryItemFilterForm
|
||||||
table = tables.InventoryItemTable
|
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)
|
@register_model_view(InventoryItem)
|
||||||
|
@ -479,7 +479,7 @@ class ReportSerializer(serializers.Serializer):
|
|||||||
module = serializers.CharField(max_length=255)
|
module = serializers.CharField(max_length=255)
|
||||||
name = serializers.CharField(max_length=255)
|
name = serializers.CharField(max_length=255)
|
||||||
description = serializers.CharField(max_length=255, required=False)
|
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()
|
result = NestedJobSerializer()
|
||||||
display = serializers.SerializerMethodField(read_only=True)
|
display = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
@ -282,7 +282,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'default': _(
|
'default': _(
|
||||||
'Invalid default value "{default}": {message}'
|
'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
|
# 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.")
|
'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
|
# Object fields must define an object_type; other fields must not
|
||||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||||
if not self.object_type:
|
if not self.object_type:
|
||||||
@ -650,19 +642,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
|
|
||||||
# Validate selected choice
|
# Validate selected choice
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
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(
|
raise ValidationError(
|
||||||
_("Invalid choice ({value}). Available choices are: {choices}").format(
|
_("Invalid choice ({value}) for choice set {choiceset}.").format(
|
||||||
value=value, choices=', '.join(self.choices)
|
value=value,
|
||||||
|
choiceset=self.choice_set
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate all selected choices
|
# Validate all selected choices
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
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(
|
raise ValidationError(
|
||||||
_("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
|
_("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
|
||||||
invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
|
value=value,
|
||||||
|
choiceset=self.choice_set
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selected object
|
# Validate selected object
|
||||||
@ -747,6 +742,13 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
|||||||
def choices_count(self):
|
def choices_count(self):
|
||||||
return len(self.choices)
|
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):
|
def clean(self):
|
||||||
if not self.base_choices and not self.extra_choices:
|
if not self.base_choices and not self.extra_choices:
|
||||||
raise ValidationError(_("Must define base or extra choices."))
|
raise ValidationError(_("Must define base or extra choices."))
|
||||||
|
@ -723,6 +723,8 @@ class ConfigRevision(models.Model):
|
|||||||
verbose_name_plural = _('config revisions')
|
verbose_name_plural = _('config revisions')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if not self.pk:
|
||||||
|
return gettext('Default configuration')
|
||||||
if self.is_active:
|
if self.is_active:
|
||||||
return gettext('Current configuration')
|
return gettext('Current configuration')
|
||||||
return gettext('Config revision #{id}').format(id=self.pk)
|
return gettext('Config revision #{id}').format(id=self.pk)
|
||||||
@ -733,6 +735,8 @@ class ConfigRevision(models.Model):
|
|||||||
return super().__getattribute__(item)
|
return super().__getattribute__(item)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
|
if not self.pk:
|
||||||
|
return reverse('core:config') # Default config view
|
||||||
return reverse('extras:configrevision', args=[self.pk])
|
return reverse('extras:configrevision', args=[self.pk])
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
|
@ -36,9 +36,10 @@ class PluginMenuItem:
|
|||||||
permissions = []
|
permissions = []
|
||||||
buttons = []
|
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 = link
|
||||||
self.link_text = link_text
|
self.link_text = link_text
|
||||||
|
self.staff_only = staff_only
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if type(permissions) not in (list, tuple):
|
if type(permissions) not in (list, tuple):
|
||||||
raise TypeError("Permissions must be passed as a tuple or list.")
|
raise TypeError("Permissions must be passed as a tuple or list.")
|
||||||
|
@ -427,6 +427,97 @@ class CustomFieldTest(TestCase):
|
|||||||
self.assertNotIn('field1', site.custom_field_data)
|
self.assertNotIn('field1', site.custom_field_data)
|
||||||
self.assertEqual(site.custom_field_data['field2'], 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):
|
class CustomFieldManagerTest(TestCase):
|
||||||
|
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ipam import models
|
|
||||||
from netaddr import AddrFormatError, IPNetwork
|
from netaddr import AddrFormatError, IPNetwork
|
||||||
|
|
||||||
__all__ = [
|
__all__ = (
|
||||||
'IPAddressField',
|
'IPAddressField',
|
||||||
]
|
'IPNetworkField',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# IP address field
|
|
||||||
#
|
|
||||||
|
|
||||||
class IPAddressField(serializers.CharField):
|
class IPAddressField(serializers.CharField):
|
||||||
"""IPAddressField with mask"""
|
"""
|
||||||
|
An IPv4 or IPv6 address with optional mask
|
||||||
|
"""
|
||||||
default_error_messages = {
|
default_error_messages = {
|
||||||
'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'),
|
'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'),
|
||||||
}
|
}
|
||||||
@ -24,7 +21,27 @@ class IPAddressField(serializers.CharField):
|
|||||||
try:
|
try:
|
||||||
return IPNetwork(data)
|
return IPNetwork(data)
|
||||||
except AddrFormatError:
|
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:
|
except (TypeError, ValueError) as e:
|
||||||
raise serializers.ValidationError(e)
|
raise serializers.ValidationError(e)
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer
|
|||||||
from utilities.api import get_serializer_for_model
|
from utilities.api import get_serializer_for_model
|
||||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||||
from .nested_serializers import *
|
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)
|
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||||
rir = NestedRIRSerializer()
|
rir = NestedRIRSerializer()
|
||||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||||
prefix = serializers.CharField()
|
prefix = IPNetworkField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Aggregate
|
model = Aggregate
|
||||||
@ -146,7 +146,6 @@ class AggregateSerializer(NetBoxModelSerializer):
|
|||||||
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
|
'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
|
||||||
'tags', 'custom_fields', 'created', 'last_updated',
|
'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
read_only_fields = ['family']
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -306,7 +305,7 @@ class PrefixSerializer(NetBoxModelSerializer):
|
|||||||
role = NestedRoleSerializer(required=False, allow_null=True)
|
role = NestedRoleSerializer(required=False, allow_null=True)
|
||||||
children = serializers.IntegerField(read_only=True)
|
children = serializers.IntegerField(read_only=True)
|
||||||
_depth = serializers.IntegerField(read_only=True)
|
_depth = serializers.IntegerField(read_only=True)
|
||||||
prefix = serializers.CharField()
|
prefix = IPNetworkField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Prefix
|
model = Prefix
|
||||||
@ -315,7 +314,6 @@ class PrefixSerializer(NetBoxModelSerializer):
|
|||||||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
|
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
|
||||||
'_depth',
|
'_depth',
|
||||||
]
|
]
|
||||||
read_only_fields = ['family']
|
|
||||||
|
|
||||||
|
|
||||||
class PrefixLengthSerializer(serializers.Serializer):
|
class PrefixLengthSerializer(serializers.Serializer):
|
||||||
@ -386,7 +384,6 @@ class IPRangeSerializer(NetBoxModelSerializer):
|
|||||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||||
]
|
]
|
||||||
read_only_fields = ['family']
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -892,7 +892,7 @@ class IPAddress(PrimaryModel):
|
|||||||
def is_oob_ip(self):
|
def is_oob_ip(self):
|
||||||
if self.assigned_object:
|
if self.assigned_object:
|
||||||
parent = getattr(self.assigned_object, 'parent_object', None)
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -900,9 +900,9 @@ class IPAddress(PrimaryModel):
|
|||||||
def is_primary_ip(self):
|
def is_primary_ip(self):
|
||||||
if self.assigned_object:
|
if self.assigned_object:
|
||||||
parent = getattr(self.assigned_object, 'parent_object', None)
|
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
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -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
|
# User preferences
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='DEFAULT_USER_PREFERENCES',
|
name='DEFAULT_USER_PREFERENCES',
|
||||||
|
@ -34,6 +34,7 @@ class MenuItem:
|
|||||||
link: str
|
link: str
|
||||||
link_text: str
|
link_text: str
|
||||||
permissions: Optional[Sequence[str]] = ()
|
permissions: Optional[Sequence[str]] = ()
|
||||||
|
staff_only: Optional[bool] = False
|
||||||
buttons: Optional[Sequence[MenuItemButton]] = ()
|
buttons: Optional[Sequence[MenuItemButton]] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@ -360,6 +360,7 @@ ADMIN_MENU = Menu(
|
|||||||
link=f'users:netboxuser_list',
|
link=f'users:netboxuser_list',
|
||||||
link_text=_('Users'),
|
link_text=_('Users'),
|
||||||
permissions=[f'auth.view_user'],
|
permissions=[f'auth.view_user'],
|
||||||
|
staff_only=True,
|
||||||
buttons=(
|
buttons=(
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:netboxuser_add',
|
link=f'users:netboxuser_add',
|
||||||
@ -382,6 +383,7 @@ ADMIN_MENU = Menu(
|
|||||||
link=f'users:netboxgroup_list',
|
link=f'users:netboxgroup_list',
|
||||||
link_text=_('Groups'),
|
link_text=_('Groups'),
|
||||||
permissions=[f'auth.view_group'],
|
permissions=[f'auth.view_group'],
|
||||||
|
staff_only=True,
|
||||||
buttons=(
|
buttons=(
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:netboxgroup_add',
|
link=f'users:netboxgroup_add',
|
||||||
@ -399,8 +401,20 @@ ADMIN_MENU = Menu(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
get_model_item('users', 'token', _('API Tokens')),
|
MenuItem(
|
||||||
get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']),
|
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(
|
MenuGroup(
|
||||||
@ -409,12 +423,14 @@ ADMIN_MENU = Menu(
|
|||||||
MenuItem(
|
MenuItem(
|
||||||
link='core:config',
|
link='core:config',
|
||||||
link_text=_('Current Config'),
|
link_text=_('Current Config'),
|
||||||
permissions=['extras.view_configrevision']
|
permissions=['extras.view_configrevision'],
|
||||||
|
staff_only=True
|
||||||
),
|
),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='extras:configrevision_list',
|
link='extras:configrevision_list',
|
||||||
link_text=_('Config Revisions'),
|
link_text=_('Config Revisions'),
|
||||||
permissions=['extras.view_configrevision']
|
permissions=['extras.view_configrevision'],
|
||||||
|
staff_only=True
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.6.0'
|
VERSION = '3.6.1'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -496,6 +496,7 @@ AUTH_EXEMPT_PATHS = (
|
|||||||
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
|
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
|
||||||
MAINTENANCE_EXEMPT_PATHS = (
|
MAINTENANCE_EXEMPT_PATHS = (
|
||||||
f'/{BASE_PATH}admin/',
|
f'/{BASE_PATH}admin/',
|
||||||
|
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||||
)
|
)
|
||||||
|
|
||||||
SERIALIZATION_MODULES = {
|
SERIALIZATION_MODULES = {
|
||||||
|
22
netbox/templates/dcim/component_list.html
Normal file
22
netbox/templates/dcim/component_list.html
Normal 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 %}
|
@ -44,17 +44,6 @@
|
|||||||
<th scope="row">{% trans "Config Template" %}</th>
|
<th scope="row">{% trans "Config Template" %}</th>
|
||||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||||
</tr>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
{% plugin_buttons object %}
|
{% 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 %}
|
{% url 'extras:configrevision_add' as edit_url %}
|
||||||
{% include "buttons/edit.html" with url=edit_url %}
|
{% include "buttons/edit.html" with url=edit_url %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% delete_button object %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -28,6 +28,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock controls %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
|
@ -32,7 +32,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Active" %}</th>
|
<th scope="row">{% trans "Active" %}</th>
|
||||||
<td>{% checkmark object.active %}</td>
|
<td>{% checkmark object.is_active %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Staff" %}</th>
|
<th scope="row">{% trans "Staff" %}</th>
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from rest_framework import serializers
|
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.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
|
||||||
from netbox.api.serializers import ValidatedModelSerializer
|
from netbox.api.serializers import ValidatedModelSerializer
|
||||||
@ -107,9 +108,42 @@ class TokenSerializer(ValidatedModelSerializer):
|
|||||||
return super().validate(data)
|
return super().validate(data)
|
||||||
|
|
||||||
|
|
||||||
class TokenProvisionSerializer(serializers.Serializer):
|
class TokenProvisionSerializer(TokenSerializer):
|
||||||
username = serializers.CharField()
|
user = NestedUserSerializer(
|
||||||
password = serializers.CharField()
|
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):
|
class ObjectPermissionSerializer(ValidatedModelSerializer):
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@ -63,34 +64,21 @@ class TokenProvisionView(APIView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
request=serializers.TokenProvisionSerializer,
|
request=serializers.TokenProvisionSerializer,
|
||||||
responses={
|
responses={
|
||||||
201: serializers.TokenSerializer,
|
201: serializers.TokenProvisionSerializer,
|
||||||
401: OpenApiTypes.OBJECT,
|
401: OpenApiTypes.OBJECT,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = serializers.TokenProvisionSerializer(data=request.data)
|
serializer = serializers.TokenProvisionSerializer(data=request.data, context={'request': request})
|
||||||
serializer.is_valid()
|
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
|
def perform_create(self, serializer):
|
||||||
username = serializer.data.get('username')
|
model = serializer.Meta.model
|
||||||
password = serializer.data.get('password')
|
logger = logging.getLogger(f'netbox.api.views.TokenProvisionView')
|
||||||
if not username or not password:
|
logger.info(f"Creating new {model._meta.verbose_name}")
|
||||||
raise AuthenticationFailed("Username and password must be provided to provision a token.")
|
serializer.save()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -141,17 +141,25 @@ class TokenTest(
|
|||||||
"""
|
"""
|
||||||
Test the provisioning of a new REST API token given a valid username and password.
|
Test the provisioning of a new REST API token given a valid username and password.
|
||||||
"""
|
"""
|
||||||
data = {
|
user_credentials = {
|
||||||
'username': 'user1',
|
'username': 'user1',
|
||||||
'password': 'abc123',
|
'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')
|
url = reverse('users-api:token_provision')
|
||||||
|
|
||||||
response = self.client.post(url, data, format='json', **self.header)
|
response = self.client.post(url, data, format='json', **self.header)
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
self.assertIn('key', response.data)
|
self.assertIn('key', response.data)
|
||||||
self.assertEqual(len(response.data['key']), 40)
|
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)
|
token = Token.objects.get(user=user)
|
||||||
self.assertEqual(token.key, response.data['key'])
|
self.assertEqual(token.key, response.data['key'])
|
||||||
|
|
||||||
|
@ -68,7 +68,7 @@ class UserView(generic.ObjectView):
|
|||||||
template_name = 'users/user.html'
|
template_name = 'users/user.html'
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
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)
|
changelog_table = ObjectChangeTable(changelog)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -26,11 +26,14 @@ def nav(context: Context) -> Dict:
|
|||||||
for group in menu.groups:
|
for group in menu.groups:
|
||||||
items = []
|
items = []
|
||||||
for item in group.items:
|
for item in group.items:
|
||||||
if user.has_perms(item.permissions):
|
if not user.has_perms(item.permissions):
|
||||||
buttons = [
|
continue
|
||||||
button for button in item.buttons if user.has_perms(button.permissions)
|
if item.staff_only and not user.is_staff:
|
||||||
]
|
continue
|
||||||
items.append((item, buttons))
|
buttons = [
|
||||||
|
button for button in item.buttons if user.has_perms(button.permissions)
|
||||||
|
]
|
||||||
|
items.append((item, buttons))
|
||||||
if items:
|
if items:
|
||||||
groups.append((group, items))
|
groups.append((group, items))
|
||||||
if groups:
|
if groups:
|
||||||
|
@ -397,7 +397,6 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
|
|||||||
template_name = 'virtualization/virtualmachine/render_config.html'
|
template_name = 'virtualization/virtualmachine/render_config.html'
|
||||||
tab = ViewTab(
|
tab = ViewTab(
|
||||||
label=_('Render Config'),
|
label=_('Render Config'),
|
||||||
permission='extras.view_configtemplate',
|
|
||||||
weight=2100
|
weight=2100
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
bleach==6.0.0
|
bleach==6.0.0
|
||||||
Django==4.2.4
|
Django==4.2.5
|
||||||
django-cors-headers==4.2.0
|
django-cors-headers==4.2.0
|
||||||
django-debug-toolbar==4.2.0
|
django-debug-toolbar==4.2.0
|
||||||
django-filter==23.2
|
django-filter==23.2
|
||||||
@ -12,23 +12,23 @@ django-rich==1.7.0
|
|||||||
django-rq==2.8.1
|
django-rq==2.8.1
|
||||||
django-tables2==2.6.0
|
django-tables2==2.6.0
|
||||||
django-taggit==4.0.0
|
django-taggit==4.0.0
|
||||||
django-timezone-field==5.1
|
django-timezone-field==6.0
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
drf-spectacular==0.26.4
|
drf-spectacular==0.26.4
|
||||||
drf-spectacular-sidecar==2023.8.1
|
drf-spectacular-sidecar==2023.9.1
|
||||||
feedparser==6.0.10
|
feedparser==6.0.10
|
||||||
graphene-django==3.0.0
|
graphene-django==3.0.0
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.3.7
|
Markdown==3.3.7
|
||||||
mkdocs-material==9.2.5
|
mkdocs-material==9.2.7
|
||||||
mkdocstrings[python-legacy]==0.22.0
|
mkdocstrings[python-legacy]==0.23.0
|
||||||
netaddr==0.8.0
|
netaddr==0.8.0
|
||||||
Pillow==10.0.0
|
Pillow==10.0.0
|
||||||
psycopg[binary,pool]==3.1.10
|
psycopg[binary,pool]==3.1.10
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
sentry-sdk==1.30.0
|
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
|
social-auth-core[openidconnect]==4.4.2
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.5.0
|
tablib==3.5.0
|
||||||
|
Loading…
Reference in New Issue
Block a user