Merge pull request #13705 from netbox-community/develop

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

View File

@ -14,7 +14,7 @@ body:
attributes: 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

View File

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

View File

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

View File

@ -65,12 +65,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 |
| `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 | | `buttons` | - | An iterable of PluginMenuButton instances to include |
!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1."
## Menu Buttons ## 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. 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -316,7 +316,7 @@ class DataFile(models.Model):
if not self.data: 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]] = ()

View File

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

View File

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

View File

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

View File

@ -44,17 +44,6 @@
<th scope="row">{% trans "Config Template" %}</th> <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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,10 @@ 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):
continue
if item.staff_only and not user.is_staff:
continue
buttons = [ buttons = [
button for button in item.buttons if user.has_perms(button.permissions) button for button in item.buttons if user.has_perms(button.permissions)
] ]

View File

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

View File

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