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:
|
||||
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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
@ -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.
|
||||
|
@ -65,12 +65,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 |
|
||||
| `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
|
||||
|
||||
Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects.
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
return None
|
||||
try:
|
||||
return bytes(self.data, 'utf-8')
|
||||
return self.data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.JSONField()
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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."))
|
||||
|
@ -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):
|
||||
|
@ -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.")
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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']
|
||||
|
||||
|
||||
#
|
||||
|
@ -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
|
||||
|
||||
|
@ -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',
|
||||
|
@ -34,6 +34,7 @@ class MenuItem:
|
||||
link: str
|
||||
link_text: str
|
||||
permissions: Optional[Sequence[str]] = ()
|
||||
staff_only: Optional[bool] = False
|
||||
buttons: Optional[Sequence[MenuItemButton]] = ()
|
||||
|
||||
|
||||
|
@ -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
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.0'
|
||||
VERSION = '3.6.1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -496,6 +496,7 @@ AUTH_EXEMPT_PATHS = (
|
||||
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
|
||||
MAINTENANCE_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}admin/',
|
||||
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
|
22
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>
|
||||
<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>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
||||
|
||||
#
|
||||
|
@ -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'])
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -26,7 +26,10 @@ def nav(context: Context) -> Dict:
|
||||
for group in menu.groups:
|
||||
items = []
|
||||
for item in group.items:
|
||||
if user.has_perms(item.permissions):
|
||||
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)
|
||||
]
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user