Merge pull request #7648 from netbox-community/5883-dyanmic-config

Closes #5883: Enable dynamic configuration of some settings
This commit is contained in:
Jeremy Stretch 2021-10-27 16:48:59 -04:00 committed by GitHub
commit 3a85edba3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 911 additions and 357 deletions

View File

@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
## Authentication ## Authentication
By default, the [`NAPALM_USERNAME`](../configuration/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
``` ```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \

View File

@ -0,0 +1,137 @@
# Dynamic Configuration Settings
These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI.
---
## ALLOWED_URL_SCHEMES
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
---
## BANNER_TOP
## BANNER_BOTTOM
Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
```python
BANNER_TOP = 'Your banner text'
BANNER_BOTTOM = BANNER_TOP
```
---
## BANNER_LOGIN
This defines custom content to be displayed on the login page above the login form. HTML is allowed.
---
## ENFORCE_GLOBAL_UNIQUE
Default: False
By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
---
## MAINTENANCE_MODE
Default: False
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
---
## MAPS_URL
Default: `https://maps.google.com/?q=` (Google Maps)
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
---
## MAX_PAGE_SIZE
Default: 1000
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
---
## NAPALM_USERNAME
## NAPALM_PASSWORD
NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
!!! note
If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
---
## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python
NAPALM_ARGS = {
'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
'port': 2222,
}
```
Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
```python
NAPALM_USERNAME = 'username'
NAPALM_PASSWORD = 'MySecretPassword'
NAPALM_ARGS = {
'secret': NAPALM_PASSWORD,
# Include any additional args here
}
```
---
## NAPALM_TIMEOUT
Default: 30 seconds
The amount of time (in seconds) to wait for NAPALM to connect to a device.
---
## PAGINATE_COUNT
Default: 50
The default maximum number of objects to display per page within each list of objects.
---
## PREFER_IPV4
Default: False
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
---
## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
Default: 22
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
---
## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
Default: 220
Default width (in pixels) of a unit within a rack elevation.

View File

@ -1,18 +1,21 @@
# NetBox Configuration # NetBox Configuration
NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below.
While NetBox has many configuration settings, only a few of them must be defined at the time of installation. Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI.
## Configuration Parameters ## Configuration Parameters
* [Required settings](required-settings.md) * [Required settings](required-settings.md)
* [Optional settings](optional-settings.md) * [Optional settings](optional-settings.md)
* [Dynamic settings](dynamic-settings.md)
## Changing the Configuration ## Changing the Configuration
Configuration settings may be changed at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect: The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
```no-highlight ```no-highlight
$ sudo systemctl restart netbox $ sudo systemctl restart netbox
``` ```
Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.

View File

@ -13,33 +13,6 @@ ADMINS = [
--- ---
## ALLOWED_URL_SCHEMES
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
---
## BANNER_TOP
## BANNER_BOTTOM
Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
```python
BANNER_TOP = 'Your banner text'
BANNER_BOTTOM = BANNER_TOP
```
---
## BANNER_LOGIN
This defines custom content to be displayed on the login page above the login form. HTML is allowed.
---
## BASE_PATH ## BASE_PATH
Default: None Default: None
@ -168,14 +141,6 @@ Email is sent from NetBox only for critical events or if configured for [logging
--- ---
## ENFORCE_GLOBAL_UNIQUE
Default: False
By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
---
## EXEMPT_VIEW_PERMISSIONS ## EXEMPT_VIEW_PERMISSIONS
Default: Empty list Default: Empty list
@ -299,30 +264,6 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
--- ---
## MAINTENANCE_MODE
Default: False
Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
---
## MAPS_URL
Default: `https://maps.google.com/?q=` (Google Maps)
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
---
## MAX_PAGE_SIZE
Default: 1000
A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
---
## MEDIA_ROOT ## MEDIA_ROOT
Default: $INSTALL_ROOT/netbox/media/ Default: $INSTALL_ROOT/netbox/media/
@ -339,57 +280,6 @@ Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Pr
--- ---
## NAPALM_USERNAME
## NAPALM_PASSWORD
NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional.
!!! note
If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
---
## NAPALM_ARGS
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
```python
NAPALM_ARGS = {
'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
'port': 2222,
}
```
Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
```python
NAPALM_USERNAME = 'username'
NAPALM_PASSWORD = 'MySecretPassword'
NAPALM_ARGS = {
'secret': NAPALM_PASSWORD,
# Include any additional args here
}
```
---
## NAPALM_TIMEOUT
Default: 30 seconds
The amount of time (in seconds) to wait for NAPALM to connect to a device.
---
## PAGINATE_COUNT
Default: 50
The default maximum number of objects to display per page within each list of objects.
---
## PLUGINS ## PLUGINS
Default: Empty Default: Empty
@ -423,30 +313,6 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff
--- ---
## PREFER_IPV4
Default: False
When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead.
---
## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
Default: 22
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
---
## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
Default: 220
Default width (in pixels) of a unit within a rack elevation.
---
## REMOTE_AUTH_AUTO_CREATE_USER ## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False` Default: `False`

View File

@ -29,6 +29,14 @@ Both types of connection include SSID and authentication attributes. Additionall
* Channel - A predefined channel within a standardized band * Channel - A predefined channel within a standardized band
* Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands)
#### Dynamic Configuration Updates ([#5883](https://github.com/netbox-community/netbox/issues/5883))
Some parameters of NetBox's configuration are now accessible via the admin UI. These parameters can be modified by an administrator and take effect immediately upon application: There is no need to restart NetBox. Additionally, each iteration of the dynamic configuration is preserved in the database, and can be restored by an administrator at any time.
Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface.
For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md).
#### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238))
Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON:

View File

@ -308,7 +308,7 @@ Vary: Accept
} }
``` ```
The default page is determined by the [`PAGINATE_COUNT`](../configuration/optional-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
``` ```
http://netbox/api/dcim/devices/?limit=100 http://netbox/api/dcim/devices/?limit=100
@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a
} }
``` ```
The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
!!! warning !!! warning
Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.

View File

@ -51,6 +51,7 @@ nav:
- Configuring NetBox: 'configuration/index.md' - Configuring NetBox: 'configuration/index.md'
- Required Settings: 'configuration/required-settings.md' - Required Settings: 'configuration/required-settings.md'
- Optional Settings: 'configuration/optional-settings.md' - Optional Settings: 'configuration/optional-settings.md'
- Dynamic Settings: 'configuration/dynamic-settings.md'
- Core Functionality: - Core Functionality:
- IP Address Management: 'core-functionality/ipam.md' - IP Address Management: 'core-functionality/ipam.md'
- VLAN Management: 'core-functionality/vlans.md' - VLAN Management: 'core-functionality/vlans.md'

View File

@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
) )
from netbox.config import ConfigItem
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=RackElevationDetailRenderChoices.RENDER_JSON default=RackElevationDetailRenderChoices.RENDER_JSON
) )
unit_width = serializers.IntegerField( unit_width = serializers.IntegerField(
default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
) )
unit_height = serializers.IntegerField( unit_height = serializers.IntegerField(
default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
) )
legend_width = serializers.IntegerField( legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT

View File

@ -1,7 +1,6 @@
import socket import socket
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings
from django.http import Http404, HttpResponse, HttpResponseForbidden from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
@ -21,6 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.exceptions import ServiceUnavailable from netbox.api.exceptions import ServiceUnavailable
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
from netbox.config import get_config
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.utils import count_related, decode_dict from utilities.utils import count_related, decode_dict
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -457,9 +457,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
napalm_methods = request.GET.getlist('method') napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods]) response = OrderedDict([(m, None) for m in napalm_methods])
username = settings.NAPALM_USERNAME
password = settings.NAPALM_PASSWORD config = get_config()
optional_args = settings.NAPALM_ARGS.copy() username = config.NAPALM_USERNAME
password = config.NAPALM_PASSWORD
timeout = config.NAPALM_TIMEOUT
optional_args = config.NAPALM_ARGS.copy()
if device.platform.napalm_args is not None: if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args) optional_args.update(device.platform.napalm_args)
@ -481,7 +484,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
hostname=host, hostname=host,
username=username, username=username,
password=password, password=password,
timeout=settings.NAPALM_TIMEOUT, timeout=timeout,
optional_args=optional_args optional_args=optional_args
) )
try: try:

View File

@ -1,7 +1,6 @@
from collections import OrderedDict from collections import OrderedDict
import yaml import yaml
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -15,6 +14,7 @@ from dcim.constants import *
from extras.models import ConfigContextModel from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -815,7 +815,7 @@ class Device(PrimaryModel, ConfigContextModel):
@property @property
def primary_ip(self): def primary_ip(self):
if settings.PREFER_IPV4 and self.primary_ip4: if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
return self.primary_ip4 return self.primary_ip4
elif self.primary_ip6: elif self.primary_ip6:
return self.primary_ip6 return self.primary_ip6

View File

@ -1,6 +1,5 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -15,6 +14,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.svg import RackElevationSVG from dcim.svg import RackElevationSVG
from extras.utils import extras_features from extras.utils import extras_features
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -373,8 +373,8 @@ class Rack(PrimaryModel):
self, self,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
user=None, user=None,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, unit_width=None,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, unit_height=None,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True, include_images=True,
base_url=None base_url=None
@ -393,6 +393,10 @@ class Rack(PrimaryModel):
:param base_url: Base URL for links and images. If none, URLs will be relative. :param base_url: Base URL for links and images. If none, URLs will be relative.
""" """
elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
if unit_width is None or unit_height is None:
config = get_config()
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
return elevation.render(face, unit_width, unit_height, legend_width) return elevation.render(face, unit_width, unit_height, legend_width)

View File

@ -160,18 +160,11 @@ class DeviceTable(BaseTable):
linkify=True, linkify=True,
verbose_name='Type' verbose_name='Type'
) )
if settings.PREFER_IPV4: primary_ip = tables.Column(
primary_ip = tables.Column( linkify=True,
linkify=True, order_by=('primary_ip4', 'primary_ip6'),
order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address'
verbose_name='IP Address' )
)
else:
primary_ip = tables.Column(
linkify=True,
order_by=('primary_ip6', 'primary_ip4'),
verbose_name='IP Address'
)
primary_ip4 = tables.Column( primary_ip4 = tables.Column(
linkify=True, linkify=True,
verbose_name='IPv4 Address' verbose_name='IPv4 Address'

View File

@ -1,10 +1,128 @@
from django.contrib import admin from django.contrib import admin
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.utils.html import format_html
from .models import JobResult from netbox.config import get_config, PARAMS
from .forms import ConfigRevisionForm
from .models import ConfigRevision, JobResult
@admin.register(ConfigRevision)
class ConfigRevisionAdmin(admin.ModelAdmin):
fieldsets = [
('Rack Elevations', {
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
}),
('IPAM', {
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
}),
('Security', {
'fields': ('ALLOWED_URL_SCHEMES',),
}),
('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
}),
('Pagination', {
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
}),
('NAPALM', {
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
}),
('Miscellaneous', {
'fields': ('MAINTENANCE_MODE', 'MAPS_URL'),
}),
('Config Revision', {
'fields': ('comment',),
})
]
form = ConfigRevisionForm
list_display = ('id', 'is_active', 'created', 'comment', 'restore_link')
ordering = ('-id',)
readonly_fields = ('data',)
def get_changeform_initial_data(self, request):
"""
Populate initial form data from the most recent ConfigRevision.
"""
latest_revision = ConfigRevision.objects.last()
initial = latest_revision.data if latest_revision else {}
initial.update(super().get_changeform_initial_data(request))
return initial
# Permissions
def has_add_permission(self, request):
# Only superusers may modify the configuration.
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
# ConfigRevisions cannot be modified once created.
return False
def has_delete_permission(self, request, obj=None):
# Only inactive ConfigRevisions may be deleted (must be superuser).
return request.user.is_superuser and (
obj is None or not obj.is_active()
)
# List display methods
def restore_link(self, obj):
if obj.is_active():
return ''
return format_html(
'<a href="{url}" class="button">Restore</a>',
url=reverse('admin:extras_configrevision_restore', args=(obj.pk,))
)
restore_link.short_description = "Actions"
# URLs
def get_urls(self):
urls = [
path('<int:pk>/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'),
]
return urls + super().get_urls()
# Views
def restore(self, request, pk):
# Get the ConfigRevision being restored
candidate_config = get_object_or_404(ConfigRevision, pk=pk)
if request.method == 'POST':
candidate_config.activate()
self.message_user(request, f"Restored configuration revision #{pk}")
return redirect(reverse('admin:extras_configrevision_changelist'))
# Get the current ConfigRevision
config_version = get_config().version
current_config = ConfigRevision.objects.filter(pk=config_version).first()
params = []
for param in PARAMS:
params.append((
param.name,
current_config.data.get(param.name, None),
candidate_config.data.get(param.name, None)
))
context = self.admin_site.each_context(request)
context.update({
'object': candidate_config,
'params': params,
})
return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context)
# #
# Reports # Reports & scripts
# #
@admin.register(JobResult) @admin.register(JobResult)

View File

@ -3,4 +3,5 @@ from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .customfields import * from .customfields import *
from .config import *
from .scripts import * from .scripts import *

View File

@ -0,0 +1,79 @@
from django import forms
from django.conf import settings
from netbox.config import get_config, PARAMS
__all__ = (
'ConfigRevisionForm',
)
EMPTY_VALUES = ('', None, [], ())
class FormMetaclass(forms.models.ModelFormMetaclass):
def __new__(mcs, name, bases, attrs):
# Emulate a declared field for each supported configuration parameter
param_fields = {}
for param in PARAMS:
field_kwargs = {
'required': False,
'label': param.label,
'help_text': param.description,
}
field_kwargs.update(**param.field_kwargs)
param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields)
return super().__new__(mcs, name, bases, attrs)
class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass):
"""
Form for creating a new ConfigRevision.
"""
class Meta:
widgets = {
'comment': forms.Textarea(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Append current parameter values to form field help texts and check for static configurations
config = get_config()
for param in PARAMS:
value = getattr(config, param.name)
is_static = hasattr(settings, param.name)
if value:
help_text = f'<br />Current value: <strong>{value}</strong>'
if is_static:
help_text += ' (defined statically)'
elif value == param.default:
help_text += ' (default)'
self.fields[param.name].help_text += help_text
if is_static:
self.fields[param.name].disabled = True
def save(self, commit=True):
instance = super().save(commit=False)
# Populate JSON data on the instance
instance.data = self.render_json()
if commit:
instance.save()
return instance
def render_json(self):
json = {}
# Iterate through each field and populate non-empty values
for field_name in self.declared_fields:
if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES:
json[field_name] = self.cleaned_data[field_name]
return json

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0063_webhook_conditions'),
]
operations = [
migrations.CreateModel(
name='ConfigRevision',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('comment', models.CharField(blank=True, max_length=200)),
('data', models.JSONField(blank=True, null=True)),
],
),
]

View File

@ -1,12 +1,13 @@
from .change_logging import ObjectChange from .change_logging import ObjectChange
from .configcontexts import ConfigContext, ConfigContextModel from .configcontexts import ConfigContext, ConfigContextModel
from .customfields import CustomField from .customfields import CustomField
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook from .models import *
from .tags import Tag, TaggedItem from .tags import Tag, TaggedItem
__all__ = ( __all__ = (
'ConfigContext', 'ConfigContext',
'ConfigContextModel', 'ConfigContextModel',
'ConfigRevision',
'CustomField', 'CustomField',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',

View File

@ -1,9 +1,11 @@
import json import json
import uuid import uuid
from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2 from utilities.utils import render_jinja2
__all__ = ( __all__ = (
'ConfigRevision',
'CustomLink', 'CustomLink',
'ExportTemplate', 'ExportTemplate',
'ImageAttachment', 'ImageAttachment',
@ -33,10 +35,6 @@ __all__ = (
) )
#
# Webhooks
#
@extras_features('webhooks') @extras_features('webhooks')
class Webhook(ChangeLoggedModel): class Webhook(ChangeLoggedModel):
""" """
@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel):
return json.dumps(context, cls=JSONEncoder) return json.dumps(context, cls=JSONEncoder)
#
# Custom links
#
@extras_features('webhooks') @extras_features('webhooks')
class CustomLink(ChangeLoggedModel): class CustomLink(ChangeLoggedModel):
""" """
@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel):
return reverse('extras:customlink', args=[self.pk]) return reverse('extras:customlink', args=[self.pk])
#
# Export templates
#
@extras_features('webhooks') @extras_features('webhooks')
class ExportTemplate(ChangeLoggedModel): class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel):
return response return response
#
# Image attachments
#
class ImageAttachment(BigIDModel): class ImageAttachment(BigIDModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel):
return None return None
#
# Journal entries
#
@extras_features('webhooks') @extras_features('webhooks')
class JournalEntry(ChangeLoggedModel): class JournalEntry(ChangeLoggedModel):
""" """
@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel):
return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) return JournalEntryKindChoices.CSS_CLASSES.get(self.kind)
#
# Custom scripts
#
@extras_features('job_results')
class Script(models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
#
# Reports
#
@extras_features('job_results')
class Report(models.Model):
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""
class Meta:
managed = False
#
# Job results
#
class JobResult(BigIDModel): class JobResult(BigIDModel):
""" """
This model stores the results from running a user-defined report. This model stores the results from running a user-defined report.
@ -582,3 +533,66 @@ class JobResult(BigIDModel):
func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
return job_result return job_result
class ConfigRevision(models.Model):
"""
An atomic revision of NetBox's configuration.
"""
created = models.DateTimeField(
auto_now_add=True
)
comment = models.CharField(
max_length=200,
blank=True
)
data = models.JSONField(
blank=True,
null=True,
verbose_name='Configuration data'
)
def __str__(self):
return f'Config revision #{self.pk} ({self.created})'
def __getattr__(self, item):
if item in self.data:
return self.data[item]
return super().__getattribute__(item)
def activate(self):
"""
Cache the configuration data.
"""
cache.set('config', self.data, None)
cache.set('config_version', self.pk, None)
@admin.display(boolean=True)
def is_active(self):
return cache.get('config_version') == self.pk
#
# Custom scripts & reports
#
@extras_features('job_results')
class Script(models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
#
# Reports
#
@extras_features('job_results')
class Report(models.Model):
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""
class Meta:
managed = False

View File

@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
from netbox.signals import post_clean from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange from .models import ConfigRevision, CustomField, ObjectChange
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
@ -161,3 +161,15 @@ def run_custom_validators(sender, instance, **kwargs):
validators = settings.CUSTOM_VALIDATORS.get(model_name, []) validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
for validator in validators: for validator in validators:
validator(instance) validator(instance)
#
# Dynamic configuration
#
@receiver(post_save, sender=ConfigRevision)
def update_config(sender, instance, **kwargs):
"""
Update the cached NetBox configuration when a new ConfigRevision is created.
"""
instance.activate()

View File

@ -1,4 +1,3 @@
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@ -9,6 +8,7 @@ from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from ipam.models import * from ipam.models import *
from netbox.config import get_config
from utilities.constants import ADVISORY_LOCK_KEYS from utilities.constants import ADVISORY_LOCK_KEYS
from . import serializers from . import serializers
@ -160,12 +160,15 @@ class AvailableIPsMixin:
# Determine the maximum number of IPs to return # Determine the maximum number of IPs to return
else: else:
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try: try:
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError: except ValueError:
limit = settings.PAGINATE_COUNT limit = PAGINATE_COUNT
if settings.MAX_PAGE_SIZE: if MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE) limit = min(limit, MAX_PAGE_SIZE)
# Calculate available IPs within the parent # Calculate available IPs within the parent
ip_list = [] ip_list = []

View File

@ -1,10 +1,9 @@
import netaddr import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -17,6 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField
from ipam.managers import IPAddressManager from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator from ipam.validators import DNSValidator
from netbox.config import get_config
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -316,7 +316,7 @@ class Prefix(PrimaryModel):
}) })
# Enforce unique IP space (if applicable) # Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_prefixes = self.get_duplicates() duplicate_prefixes = self.get_duplicates()
if duplicate_prefixes: if duplicate_prefixes:
raise ValidationError({ raise ValidationError({
@ -811,7 +811,7 @@ class IPAddress(PrimaryModel):
}) })
# Enforce unique IP space (if applicable) # Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
duplicate_ips = self.get_duplicates() duplicate_ips = self.get_duplicates()
if duplicate_ips and ( if duplicate_ips and (
self.role not in IPADDRESS_ROLES_NONUNIQUE or self.role not in IPADDRESS_ROLES_NONUNIQUE or

View File

@ -1,7 +1,8 @@
from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from netbox.config import get_config
class OptionalLimitOffsetPagination(LimitOffsetPagination): class OptionalLimitOffsetPagination(LimitOffsetPagination):
""" """
@ -9,6 +10,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
matching a query, but retains the same format as a paginated request. The limit can only be disabled if matching a query, but retains the same format as a paginated request. The limit can only be disabled if
MAX_PAGE_SIZE has been set to 0 or None. MAX_PAGE_SIZE has been set to 0 or None.
""" """
def __init__(self):
self.default_limit = get_config().PAGINATE_COUNT
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
@ -40,11 +43,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
if limit < 0: if limit < 0:
raise ValueError() raise ValueError()
# Enforce maximum page size, if defined # Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE: MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
if limit == 0: if MAX_PAGE_SIZE:
return settings.MAX_PAGE_SIZE return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit return limit
except (KeyError, ValueError): except (KeyError, ValueError):
pass pass

View File

@ -0,0 +1,105 @@
import logging
import threading
from django.conf import settings
from django.core.cache import cache
from django.db.utils import DatabaseError
from .parameters import PARAMS
__all__ = (
'clear_config',
'ConfigItem',
'get_config',
'PARAMS',
)
_thread_locals = threading.local()
logger = logging.getLogger('netbox.config')
def get_config():
"""
Return the current NetBox configuration, pulling it from cache if not already loaded in memory.
"""
if not hasattr(_thread_locals, 'config'):
_thread_locals.config = Config()
logger.debug("Initialized configuration")
return _thread_locals.config
def clear_config():
"""
Delete the currently loaded configuration, if any.
"""
if hasattr(_thread_locals, 'config'):
del _thread_locals.config
logger.debug("Cleared configuration")
class Config:
"""
Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and
must be re-instantiated each time it's necessary to check for updates to the cached config.
"""
def __init__(self):
self._populate_from_cache()
if not self.config or not self.version:
self._populate_from_db()
self.defaults = {param.name: param.default for param in PARAMS}
def __getattr__(self, item):
# Check for hard-coded configuration in settings.py
if hasattr(settings, item):
return getattr(settings, item)
# Return config value from cache
if item in self.config:
return self.config[item]
# Fall back to the parameter's default value
if item in self.defaults:
return self.defaults[item]
raise AttributeError(f"Invalid configuration parameter: {item}")
def _populate_from_cache(self):
"""Populate config data from Redis cache"""
self.config = cache.get('config') or {}
self.version = cache.get('config_version')
if self.config:
logger.debug("Loaded configuration data from cache")
def _populate_from_db(self):
"""Cache data from latest ConfigRevision, then populate from cache"""
from extras.models import ConfigRevision
try:
revision = ConfigRevision.objects.last()
if revision is None:
logger.debug("No previous configuration found in database; proceeding with default values")
return
logger.debug("Loaded configuration data from database")
except DatabaseError:
# The database may not be available yet (e.g. when running a management command)
logger.warning(f"Skipping config initialization (database unavailable)")
return
revision.activate()
logger.debug("Filled cache with data from latest ConfigRevision")
self._populate_from_cache()
class ConfigItem:
"""
A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer
referencing a configuration parameter.
"""
def __init__(self, item):
self.item = item
def __call__(self):
config = get_config()
return getattr(config, self.item)

View File

@ -0,0 +1,140 @@
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
class ConfigParam:
def __init__(self, name, label, default, description='', field=None, field_kwargs=None):
self.name = name
self.label = label
self.default = default
self.field = field or forms.CharField
self.description = description
self.field_kwargs = field_kwargs or {}
PARAMS = (
# Banners
ConfigParam(
name='BANNER_LOGIN',
label='Login banner',
default='',
description="Additional content to display on the login page"
),
ConfigParam(
name='BANNER_TOP',
label='Top banner',
default='',
description="Additional content to display at the top of every page"
),
ConfigParam(
name='BANNER_BOTTOM',
label='Bottom banner',
default='',
description="Additional content to display at the bottom of every page"
),
# IPAM
ConfigParam(
name='ENFORCE_GLOBAL_UNIQUE',
label='Globally unique IP space',
default=False,
description="Enforce unique IP addressing within the global table",
field=forms.BooleanField
),
ConfigParam(
name='PREFER_IPV4',
label='Prefer IPv4',
default=False,
description="Prefer IPv4 addresses over IPv6",
field=forms.BooleanField
),
# Racks
ConfigParam(
name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT',
label='Rack unit height',
default=22,
description="Default unit height for rendered rack elevations",
field=forms.IntegerField
),
ConfigParam(
name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH',
label='Rack unit width',
default=220,
description="Default unit width for rendered rack elevations",
field=forms.IntegerField
),
# Security
ConfigParam(
name='ALLOWED_URL_SCHEMES',
label='Allowed URL schemes',
default=(
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc',
'xmpp',
),
description="Permitted schemes for URLs in user-provided content",
field=SimpleArrayField,
field_kwargs={'base_field': forms.CharField()}
),
# Pagination
ConfigParam(
name='PAGINATE_COUNT',
label='Default page size',
default=50,
field=forms.IntegerField
),
ConfigParam(
name='MAX_PAGE_SIZE',
label='Maximum page size',
default=1000,
field=forms.IntegerField
),
# 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
),
# Miscellaneous
ConfigParam(
name='MAINTENANCE_MODE',
label='Maintenance mode',
default=False,
description="Enable maintenance mode",
field=forms.BooleanField
),
ConfigParam(
name='MAPS_URL',
label='Maps URL',
default='https://maps.google.com/?q=',
description="Base URL for mapping geographic locations"
),
)

View File

@ -72,19 +72,6 @@ ADMINS = [
# ('John Doe', 'jdoe@example.com'), # ('John Doe', 'jdoe@example.com'),
] ]
# URL schemes that are allowed within links in NetBox
ALLOWED_URL_SCHEMES = (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
)
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = ''
BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set:
# BASE_PATH = 'netbox/' # BASE_PATH = 'netbox/'
BASE_PATH = '' BASE_PATH = ''
@ -134,10 +121,6 @@ EMAIL = {
'FROM_EMAIL': '', 'FROM_EMAIL': '',
} }
# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
ENFORCE_GLOBAL_UNIQUE = False
# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models. # by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
EXEMPT_VIEW_PERMISSIONS = [ EXEMPT_VIEW_PERMISSIONS = [
@ -175,17 +158,6 @@ LOGIN_REQUIRED = False
# re-authenticate. (Default: 1209600 [14 days]) # re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None LOGIN_TIMEOUT = None
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False
# The URL to use when mapping physical addresses or GPS coordinates
MAPS_URL = 'https://maps.google.com/?q='
# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g.
# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request
# all objects by specifying "?limit=0".
MAX_PAGE_SIZE = 1000
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
# the default value of this setting is derived from the installed location. # the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media' # MEDIA_ROOT = '/opt/netbox/netbox/media'
@ -203,20 +175,6 @@ MAX_PAGE_SIZE = 1000
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
METRICS_ENABLED = False METRICS_ENABLED = False
# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
NAPALM_USERNAME = ''
NAPALM_PASSWORD = ''
# NAPALM timeout (in seconds). (Default: 30)
NAPALM_TIMEOUT = 30
# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must
# be provided as a dictionary.
NAPALM_ARGS = {}
# Determine how many objects to display per page within a list. (Default: 50)
PAGINATE_COUNT = 50
# Enable installed plugins. Add the name of each plugin to the list. # Enable installed plugins. Add the name of each plugin to the list.
PLUGINS = [] PLUGINS = []
@ -229,14 +187,6 @@ PLUGINS = []
# } # }
# } # }
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
# prefer IPv4 instead.
PREFER_IPV4 = False
# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1.
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220
# Remote authentication support # Remote authentication support
REMOTE_AUTH_ENABLED = False REMOTE_AUTH_ENABLED = False
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'

View File

@ -1,6 +1,7 @@
from django.conf import settings as django_settings from django.conf import settings as django_settings
from extras.registry import registry from extras.registry import registry
from netbox.config import get_config
def settings_and_registry(request): def settings_and_registry(request):
@ -9,6 +10,7 @@ def settings_and_registry(request):
""" """
return { return {
'settings': django_settings, 'settings': django_settings,
'config': get_config(),
'registry': registry, 'registry': registry,
'preferences': request.user.config if request.user.is_authenticated else {}, 'preferences': request.user.config if request.user.is_authenticated else {},
} }

View File

@ -11,11 +11,12 @@ from django.http import Http404, HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from extras.context_managers import change_logging from extras.context_managers import change_logging
from netbox.config import clear_config
from netbox.views import server_error from netbox.views import server_error
from utilities.api import is_api_request, rest_api_server_error from utilities.api import is_api_request, rest_api_server_error
class LoginRequiredMiddleware(object): class LoginRequiredMiddleware:
""" """
If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page.
""" """
@ -114,7 +115,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return groups return groups
class ObjectChangeMiddleware(object): class ObjectChangeMiddleware:
""" """
This middleware performs three functions in response to an object being created, updated, or deleted: This middleware performs three functions in response to an object being created, updated, or deleted:
@ -144,7 +145,7 @@ class ObjectChangeMiddleware(object):
return response return response
class APIVersionMiddleware(object): class APIVersionMiddleware:
""" """
If the request is for an API endpoint, include the API version as a response header. If the request is for an API endpoint, include the API version as a response header.
""" """
@ -159,7 +160,20 @@ class APIVersionMiddleware(object):
return response return response
class ExceptionHandlingMiddleware(object): class DynamicConfigMiddleware:
"""
Store the cached NetBox configuration in thread-local storage for the duration of the request.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
clear_config()
return response
class ExceptionHandlingMiddleware:
""" """
Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions Intercept certain exceptions which are likely indicative of installation issues and provide helpful instructions
to the user. to the user.

View File

@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages
from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.validators import URLValidator from django.core.validators import URLValidator
from netbox.config import PARAMS
# #
# Environment setup # Environment setup
@ -68,14 +70,8 @@ DATABASE = getattr(configuration, 'DATABASE')
REDIS = getattr(configuration, 'REDIS') REDIS = getattr(configuration, 'REDIS')
SECRET_KEY = getattr(configuration, 'SECRET_KEY') SECRET_KEY = getattr(configuration, 'SECRET_KEY')
# Set optional parameters # Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
))
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH: if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
@ -90,30 +86,19 @@ DEBUG = getattr(configuration, 'DEBUG', False)
DEVELOPER = getattr(configuration, 'DEVELOPER', False) DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
EMAIL = getattr(configuration, 'EMAIL', {}) EMAIL = getattr(configuration, 'EMAIL', {})
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
LOGGING = getattr(configuration, 'LOGGING', {}) LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=')
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22)
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
@ -127,7 +112,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
@ -141,6 +125,11 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
# Check for hard-coded dynamic config parameters
for param in PARAMS:
if hasattr(configuration, param.name):
globals()[param.name] = getattr(configuration, param.name)
# Validate update repo URL and timeout # Validate update repo URL and timeout
if RELEASE_CHECK_URL: if RELEASE_CHECK_URL:
validator = URLValidator( validator = URLValidator(
@ -346,6 +335,7 @@ MIDDLEWARE = [
'netbox.middleware.ExceptionHandlingMiddleware', 'netbox.middleware.ExceptionHandlingMiddleware',
'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.LoginRequiredMiddleware', 'netbox.middleware.LoginRequiredMiddleware',
'netbox.middleware.DynamicConfigMiddleware',
'netbox.middleware.APIVersionMiddleware', 'netbox.middleware.APIVersionMiddleware',
'netbox.middleware.ObjectChangeMiddleware', 'netbox.middleware.ObjectChangeMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware',
@ -466,7 +456,7 @@ REST_FRAMEWORK = {
), ),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION, 'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT, # 'PAGE_SIZE': PAGINATE_COUNT,
'SCHEMA_COERCE_METHOD_NAMES': { 'SCHEMA_COERCE_METHOD_NAMES': {
# Default mappings # Default mappings
'retrieve': 'read', 'retrieve': 'read',
@ -565,23 +555,6 @@ RQ_QUEUES = {
} }
#
# NetBox internal settings
#
# Pagination
if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
raise ImproperlyConfigured(
f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
)
PER_PAGE_DEFAULTS = [
25, 50, 100, 250, 500, 1000
]
if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
# #
# Plugins # Plugins
# #

View File

@ -0,0 +1,53 @@
from django.conf import settings
from django.core.cache import cache
from django.test import override_settings, TestCase
from extras.models import ConfigRevision
from netbox.config import clear_config, get_config
# Prefix cache keys to avoid interfering with the local environment
CACHES = settings.CACHES
CACHES['default'].update({'KEY_PREFIX': 'TEST-'})
class ConfigTestCase(TestCase):
@override_settings(CACHES=CACHES)
def test_config_init_empty(self):
cache.clear()
config = get_config()
self.assertEqual(config.config, {})
self.assertEqual(config.version, None)
clear_config()
@override_settings(CACHES=CACHES)
def test_config_init_from_db(self):
CONFIG_DATA = {'BANNER_TOP': 'A'}
cache.clear()
# Create a config but don't load it into the cache
configrevision = ConfigRevision.objects.create(data=CONFIG_DATA)
config = get_config()
self.assertEqual(config.config, CONFIG_DATA)
self.assertEqual(config.version, configrevision.pk)
clear_config()
@override_settings(CACHES=CACHES)
def test_config_init_from_cache(self):
CONFIG_DATA = {'BANNER_TOP': 'B'}
cache.clear()
# Create a config and load it into the cache
configrevision = ConfigRevision.objects.create(data=CONFIG_DATA)
configrevision.activate()
config = get_config()
self.assertEqual(config.config, CONFIG_DATA)
self.assertEqual(config.version, configrevision.pk)
clear_config()

View File

@ -0,0 +1,37 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block content %}
<p>Restore configuration #{{ object.pk }} from <strong>{{ object.created }}</strong>?</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Current Value</th>
<th>New Value</th>
<th></th>
</tr>
</thead>
<tbody>
{% for param, current, new in params %}
<tr{% if current != new %} style="color: #d7a50d"{% endif %}>
<td>{{ param }}</td>
<td>{{ current }}</td>
<td>{{ new }}</td>
<td>{% if current != new %}<img src="{% static 'admin/img/icon-changelink.svg' %}" alt="*" title="Changed">{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<form method="post">
{% csrf_token %}
<div class="submit-row" style="margin-top: 20px">
<input type="submit" name="restore" value="Restore" class="default" style="float: left" />
<a href="{% url 'admin:extras_configrevision_changelist' %}" style="float: left; margin: 2px 0; padding: 10px 15px">Cancel</a>
</div>
</form>
{% endblock content %}

View File

@ -58,13 +58,13 @@
</nav> </nav>
{% if settings.BANNER_TOP %} {% if config.BANNER_TOP %}
<div class="alert alert-info text-center mx-3" role="alert"> <div class="alert alert-info text-center mx-3" role="alert">
{{ settings.BANNER_TOP|safe }} {{ config.BANNER_TOP|safe }}
</div> </div>
{% endif %} {% endif %}
{% if settings.MAINTENANCE_MODE %} {% if config.MAINTENANCE_MODE %}
<div class="alert alert-warning text-center mx-3" role="alert"> <div class="alert alert-warning text-center mx-3" role="alert">
<h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4> <h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
<span>NetBox is currently in maintenance mode. Functionality may be limited.</span> <span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
@ -98,9 +98,9 @@
{% endblock %} {% endblock %}
</div> </div>
{% if settings.BANNER_BOTTOM %} {% if config.BANNER_BOTTOM %}
<div class="alert alert-info text-center mx-3" role="alert"> <div class="alert alert-info text-center mx-3" role="alert">
{{ settings.BANNER_BOTTOM|safe }} {{ config.BANNER_BOTTOM|safe }}
</div> </div>
{% endif %} {% endif %}

View File

@ -100,7 +100,7 @@
<td> <td>
{% if object.physical_address %} {% if object.physical_address %}
<div class="float-end noprint"> <div class="float-end noprint">
<a href="{{ settings.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm"> <a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It <i class="mdi mdi-map-marker"></i> Map It
</a> </a>
</div> </div>
@ -119,7 +119,7 @@
<td> <td>
{% if object.latitude and object.longitude %} {% if object.latitude and object.longitude %}
<div class="float-end noprint"> <div class="float-end noprint">
<a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm"> <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It <i class="mdi mdi-map-marker"></i> Map It
</a> </a>
</div> </div>

View File

@ -36,7 +36,7 @@
{% endfor %} {% endfor %}
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<select name="per_page" class="form-select per-page"> <select name="per_page" class="form-select per-page">
{% for n in settings.PER_PAGE_DEFAULTS %} {% for n in page.paginator.get_page_lengths %}
<option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option> <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
{% endfor %} {% endfor %}
</select> </select>

View File

@ -7,9 +7,9 @@
<main class="login-container text-center"> <main class="login-container text-center">
{# Login banner #} {# Login banner #}
{% if settings.BANNER_LOGIN %} {% if config.BANNER_LOGIN %}
<div class="alert alert-secondary mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50" role="alert"> <div class="alert alert-secondary mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50" role="alert">
{{ settings.BANNER_LOGIN|safe }} {{ config.BANNER_LOGIN|safe }}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,6 +1,5 @@
import logging import logging
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@ -14,6 +13,7 @@ from django.utils.http import is_safe_url
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View from django.views.generic import View
from netbox.config import get_config
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm, TokenForm from .forms import LoginForm, PasswordChangeForm, TokenForm
from .models import Token from .models import Token
@ -53,7 +53,7 @@ class LoginView(View):
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication. # last_login time upon authentication.
if settings.MAINTENANCE_MODE: if get_config().MAINTENANCE_MODE:
logger.warning("Maintenance mode enabled: disabling update of most recent login time") logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login') user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')

View File

@ -1,8 +1,12 @@
from django.conf import settings
from django.core.paginator import Paginator, Page from django.core.paginator import Paginator, Page
from netbox.config import get_config
class EnhancedPaginator(Paginator): class EnhancedPaginator(Paginator):
default_page_lengths = (
25, 50, 100, 250, 500, 1000
)
def __init__(self, object_list, per_page, orphans=None, **kwargs): def __init__(self, object_list, per_page, orphans=None, **kwargs):
@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator):
try: try:
per_page = int(per_page) per_page = int(per_page)
if per_page < 1: if per_page < 1:
per_page = settings.PAGINATE_COUNT per_page = get_config().PAGINATE_COUNT
except ValueError: except ValueError:
per_page = settings.PAGINATE_COUNT per_page = get_config().PAGINATE_COUNT
# Set orphans count based on page size # Set orphans count based on page size
if orphans is None and per_page <= 50: if orphans is None and per_page <= 50:
@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator):
def _get_page(self, *args, **kwargs): def _get_page(self, *args, **kwargs):
return EnhancedPage(*args, **kwargs) return EnhancedPage(*args, **kwargs)
def get_page_lengths(self):
if self.per_page not in self.default_page_lengths:
return sorted([*self.default_page_lengths, self.per_page])
return self.default_page_lengths
class EnhancedPage(Page): class EnhancedPage(Page):
@ -57,17 +66,19 @@ def get_paginate_count(request):
Return the lesser of the calculated value and MAX_PAGE_SIZE. Return the lesser of the calculated value and MAX_PAGE_SIZE.
""" """
config = get_config()
if 'per_page' in request.GET: if 'per_page' in request.GET:
try: try:
per_page = int(request.GET.get('per_page')) per_page = int(request.GET.get('per_page'))
if request.user.is_authenticated: if request.user.is_authenticated:
request.user.config.set('pagination.per_page', per_page, commit=True) request.user.config.set('pagination.per_page', per_page, commit=True)
return min(per_page, settings.MAX_PAGE_SIZE) return min(per_page, config.MAX_PAGE_SIZE)
except ValueError: except ValueError:
pass pass
if request.user.is_authenticated: if request.user.is_authenticated:
per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT) per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
return min(per_page, settings.MAX_PAGE_SIZE) return min(per_page, config.MAX_PAGE_SIZE)
return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE) return min(config.PAGINATE_COUNT, config.MAX_PAGE_SIZE)

View File

@ -14,6 +14,7 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from markdown import markdown from markdown import markdown
from netbox.config import get_config
from utilities.forms import get_selected_values, TableConfigForm from utilities.forms import get_selected_values, TableConfigForm
from utilities.utils import foreground_color from utilities.utils import foreground_color
@ -44,7 +45,7 @@ def render_markdown(value):
value = strip_tags(value) value = strip_tags(value)
# Sanitize Markdown links # Sanitize Markdown links
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)

View File

@ -1,6 +1,5 @@
import urllib.parse import urllib.parse
from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
@ -10,6 +9,7 @@ from dcim.models import Region, Site
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
from ipam.models import VLAN from ipam.models import VLAN
from netbox.config import get_config
from utilities.testing import APITestCase, disable_warnings from utilities.testing import APITestCase, disable_warnings
@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase):
def test_default_page_size(self): def test_default_page_size(self):
response = self.client.get(self.url, format='json', **self.header) response = self.client.get(self.url, format='json', **self.header)
page_size = settings.PAGINATE_COUNT page_size = get_config().PAGINATE_COUNT
self.assertLess(page_size, 100, "Default page size not sufficient for data set") self.assertLess(page_size, 100, "Default page size not sufficient for data set")
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)

View File

@ -1,9 +1,10 @@
import re import re
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
from netbox.config import get_config
class EnhancedURLValidator(URLValidator): class EnhancedURLValidator(URLValidator):
""" """
@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator):
r'(?::\d{2,5})?' # Port number r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE) r'\Z', re.IGNORECASE)
schemes = settings.ALLOWED_URL_SCHEMES
def __init__(self, schemes=None, **kwargs):
super().__init__(**kwargs)
if schemes is not None:
self.schemes = get_config().ALLOWED_URL_SCHEMES
class ExclusionValidator(BaseValidator): class ExclusionValidator(BaseValidator):

View File

@ -1,4 +1,3 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device
from extras.models import ConfigContextModel from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
@ -340,7 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
@property @property
def primary_ip(self): def primary_ip(self):
if settings.PREFER_IPV4 and self.primary_ip4: if get_config().PREFER_IPV4 and self.primary_ip4:
return self.primary_ip4 return self.primary_ip4
elif self.primary_ip6: elif self.primary_ip6:
return self.primary_ip6 return self.primary_ip6

View File

@ -17,8 +17,6 @@ __all__ = (
'VMInterfaceTable', 'VMInterfaceTable',
) )
PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
VMINTERFACE_BUTTONS = """ VMINTERFACE_BUTTONS = """
{% if perms.ipam.add_ipaddress %} {% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address"> <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ record.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-sm btn-success" title="Add IP Address">
@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable):
) )
primary_ip = tables.Column( primary_ip = tables.Column(
linkify=True, linkify=True,
order_by=PRIMARY_IP_ORDERING, order_by=('primary_ip4', 'primary_ip6'),
verbose_name='IP Address' verbose_name='IP Address'
) )
tags = TagColumn( tags = TagColumn(