mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge pull request #7648 from netbox-community/5883-dyanmic-config
Closes #5883: Enable dynamic configuration of some settings
This commit is contained in:
commit
3a85edba3d
@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
|
||||
|
||||
## 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" \
|
||||
|
137
docs/configuration/dynamic-settings.md
Normal file
137
docs/configuration/dynamic-settings.md
Normal 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.
|
@ -1,18 +1,21 @@
|
||||
# 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
|
||||
|
||||
* [Required settings](required-settings.md)
|
||||
* [Optional settings](optional-settings.md)
|
||||
* [Dynamic settings](dynamic-settings.md)
|
||||
|
||||
## 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
|
||||
$ sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
Configuration parameters which are set via the admin UI (those listed under "dynamic settings") take effect immediately.
|
||||
|
@ -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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
Default: `False`
|
||||
|
@ -29,6 +29,14 @@ Both types of connection include SSID and authentication attributes. Additionall
|
||||
* Channel - A predefined channel within a standardized band
|
||||
* 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))
|
||||
|
||||
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:
|
||||
|
@ -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
|
||||
@ -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
|
||||
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.
|
||||
|
@ -51,6 +51,7 @@ nav:
|
||||
- Configuring NetBox: 'configuration/index.md'
|
||||
- Required Settings: 'configuration/required-settings.md'
|
||||
- Optional Settings: 'configuration/optional-settings.md'
|
||||
- Dynamic Settings: 'configuration/dynamic-settings.md'
|
||||
- Core Functionality:
|
||||
- IP Address Management: 'core-functionality/ipam.md'
|
||||
- VLAN Management: 'core-functionality/vlans.md'
|
||||
|
@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import (
|
||||
NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
)
|
||||
from netbox.config import ConfigItem
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
default=RackElevationDetailRenderChoices.RENDER_JSON
|
||||
)
|
||||
unit_width = serializers.IntegerField(
|
||||
default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
||||
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH')
|
||||
)
|
||||
unit_height = serializers.IntegerField(
|
||||
default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
||||
default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT')
|
||||
)
|
||||
legend_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||
|
@ -1,7 +1,6 @@
|
||||
import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
@ -21,6 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.config import get_config
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related, decode_dict
|
||||
from virtualization.models import VirtualMachine
|
||||
@ -457,9 +457,12 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
username = settings.NAPALM_USERNAME
|
||||
password = settings.NAPALM_PASSWORD
|
||||
optional_args = settings.NAPALM_ARGS.copy()
|
||||
|
||||
config = get_config()
|
||||
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:
|
||||
optional_args.update(device.platform.napalm_args)
|
||||
|
||||
@ -481,7 +484,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
|
||||
hostname=host,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=settings.NAPALM_TIMEOUT,
|
||||
timeout=timeout,
|
||||
optional_args=optional_args
|
||||
)
|
||||
try:
|
||||
|
@ -1,7 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -15,6 +14,7 @@ from dcim.constants import *
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@ -815,7 +815,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
if settings.PREFER_IPV4 and self.primary_ip4:
|
||||
if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
|
||||
return self.primary_ip4
|
||||
elif self.primary_ip6:
|
||||
return self.primary_ip6
|
||||
|
@ -1,6 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@ -15,6 +14,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from extras.utils import extras_features
|
||||
from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@ -373,8 +373,8 @@ class Rack(PrimaryModel):
|
||||
self,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
user=None,
|
||||
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
|
||||
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
|
||||
unit_width=None,
|
||||
unit_height=None,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
include_images=True,
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
|
@ -160,18 +160,11 @@ class DeviceTable(BaseTable):
|
||||
linkify=True,
|
||||
verbose_name='Type'
|
||||
)
|
||||
if settings.PREFER_IPV4:
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
else:
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip6', 'primary_ip4'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip4 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv4 Address'
|
||||
|
@ -1,10 +1,128 @@
|
||||
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)
|
||||
|
@ -3,4 +3,5 @@ from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .customfields import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
79
netbox/extras/forms/config.py
Normal file
79
netbox/extras/forms/config.py
Normal 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
|
20
netbox/extras/migrations/0064_configrevision.py
Normal file
20
netbox/extras/migrations/0064_configrevision.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,12 +1,13 @@
|
||||
from .change_logging import ObjectChange
|
||||
from .configcontexts import ConfigContext, ConfigContextModel
|
||||
from .customfields import CustomField
|
||||
from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook
|
||||
from .models import *
|
||||
from .tags import Tag, TaggedItem
|
||||
|
||||
__all__ = (
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'ConfigRevision',
|
||||
'CustomField',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
|
@ -1,9 +1,11 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
'CustomLink',
|
||||
'ExportTemplate',
|
||||
'ImageAttachment',
|
||||
@ -33,10 +35,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class Webhook(ChangeLoggedModel):
|
||||
"""
|
||||
@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel):
|
||||
return json.dumps(context, cls=JSONEncoder)
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class CustomLink(ChangeLoggedModel):
|
||||
"""
|
||||
@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel):
|
||||
return reverse('extras:customlink', args=[self.pk])
|
||||
|
||||
|
||||
#
|
||||
# Export templates
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ExportTemplate(ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel):
|
||||
return response
|
||||
|
||||
|
||||
#
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachment(BigIDModel):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel):
|
||||
return None
|
||||
|
||||
|
||||
#
|
||||
# Journal entries
|
||||
#
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class JournalEntry(ChangeLoggedModel):
|
||||
"""
|
||||
@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel):
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
|
@ -8,7 +8,7 @@ from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from netbox.signals import post_clean
|
||||
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
|
||||
|
||||
|
||||
@ -161,3 +161,15 @@ def run_custom_validators(sender, instance, **kwargs):
|
||||
validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
|
||||
for validator in validators:
|
||||
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()
|
||||
|
@ -1,4 +1,3 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
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 ipam.models import *
|
||||
from netbox.config import get_config
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from . import serializers
|
||||
|
||||
@ -160,12 +160,15 @@ class AvailableIPsMixin:
|
||||
|
||||
# Determine the maximum number of IPs to return
|
||||
else:
|
||||
config = get_config()
|
||||
PAGINATE_COUNT = config.PAGINATE_COUNT
|
||||
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
||||
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
|
||||
except ValueError:
|
||||
limit = settings.PAGINATE_COUNT
|
||||
if settings.MAX_PAGE_SIZE:
|
||||
limit = min(limit, settings.MAX_PAGE_SIZE)
|
||||
limit = PAGINATE_COUNT
|
||||
if MAX_PAGE_SIZE:
|
||||
limit = min(limit, MAX_PAGE_SIZE)
|
||||
|
||||
# Calculate available IPs within the parent
|
||||
ip_list = []
|
||||
|
@ -1,10 +1,9 @@
|
||||
import netaddr
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
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.utils.functional import cached_property
|
||||
|
||||
@ -17,6 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField
|
||||
from ipam.managers import IPAddressManager
|
||||
from ipam.querysets import PrefixQuerySet
|
||||
from ipam.validators import DNSValidator
|
||||
from netbox.config import get_config
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
@ -316,7 +316,7 @@ class Prefix(PrimaryModel):
|
||||
})
|
||||
|
||||
# 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()
|
||||
if duplicate_prefixes:
|
||||
raise ValidationError({
|
||||
@ -811,7 +811,7 @@ class IPAddress(PrimaryModel):
|
||||
})
|
||||
|
||||
# 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()
|
||||
if duplicate_ips and (
|
||||
self.role not in IPADDRESS_ROLES_NONUNIQUE or
|
||||
|
@ -1,7 +1,8 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
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
|
||||
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):
|
||||
|
||||
@ -40,11 +43,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
if limit < 0:
|
||||
raise ValueError()
|
||||
# Enforce maximum page size, if defined
|
||||
if settings.MAX_PAGE_SIZE:
|
||||
if limit == 0:
|
||||
return settings.MAX_PAGE_SIZE
|
||||
else:
|
||||
return min(limit, settings.MAX_PAGE_SIZE)
|
||||
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
||||
if MAX_PAGE_SIZE:
|
||||
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
|
||||
return limit
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
105
netbox/netbox/config/__init__.py
Normal file
105
netbox/netbox/config/__init__.py
Normal 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)
|
140
netbox/netbox/config/parameters.py
Normal file
140
netbox/netbox/config/parameters.py
Normal 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"
|
||||
),
|
||||
|
||||
)
|
@ -72,19 +72,6 @@ ADMINS = [
|
||||
# ('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_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
@ -134,10 +121,6 @@ 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
|
||||
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
@ -175,17 +158,6 @@ LOGIN_REQUIRED = False
|
||||
# re-authenticate. (Default: 1209600 [14 days])
|
||||
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 default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
@ -203,20 +175,6 @@ MAX_PAGE_SIZE = 1000
|
||||
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
|
||||
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.
|
||||
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_AUTH_ENABLED = False
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from extras.registry import registry
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
def settings_and_registry(request):
|
||||
@ -9,6 +10,7 @@ def settings_and_registry(request):
|
||||
"""
|
||||
return {
|
||||
'settings': django_settings,
|
||||
'config': get_config(),
|
||||
'registry': registry,
|
||||
'preferences': request.user.config if request.user.is_authenticated else {},
|
||||
}
|
||||
|
@ -11,11 +11,12 @@ from django.http import Http404, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.context_managers import change_logging
|
||||
from netbox.config import clear_config
|
||||
from netbox.views import 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.
|
||||
"""
|
||||
@ -114,7 +115,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
|
||||
return groups
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
class ObjectChangeMiddleware:
|
||||
"""
|
||||
This middleware performs three functions in response to an object being created, updated, or deleted:
|
||||
|
||||
@ -144,7 +145,7 @@ class ObjectChangeMiddleware(object):
|
||||
return response
|
||||
|
||||
|
||||
class APIVersionMiddleware(object):
|
||||
class APIVersionMiddleware:
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
to the user.
|
||||
|
@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
from netbox.config import PARAMS
|
||||
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
@ -68,14 +70,8 @@ DATABASE = getattr(configuration, 'DATABASE')
|
||||
REDIS = getattr(configuration, 'REDIS')
|
||||
SECRET_KEY = getattr(configuration, 'SECRET_KEY')
|
||||
|
||||
# Set optional parameters
|
||||
# Set static config parameters
|
||||
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', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
@ -90,30 +86,19 @@ DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
|
||||
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
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('/')
|
||||
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_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
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)
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
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_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_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
|
||||
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('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
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_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
|
||||
if RELEASE_CHECK_URL:
|
||||
validator = URLValidator(
|
||||
@ -346,6 +335,7 @@ MIDDLEWARE = [
|
||||
'netbox.middleware.ExceptionHandlingMiddleware',
|
||||
'netbox.middleware.RemoteUserMiddleware',
|
||||
'netbox.middleware.LoginRequiredMiddleware',
|
||||
'netbox.middleware.DynamicConfigMiddleware',
|
||||
'netbox.middleware.APIVersionMiddleware',
|
||||
'netbox.middleware.ObjectChangeMiddleware',
|
||||
'django_prometheus.middleware.PrometheusAfterMiddleware',
|
||||
@ -466,7 +456,7 @@ REST_FRAMEWORK = {
|
||||
),
|
||||
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
|
||||
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
|
||||
'PAGE_SIZE': PAGINATE_COUNT,
|
||||
# 'PAGE_SIZE': PAGINATE_COUNT,
|
||||
'SCHEMA_COERCE_METHOD_NAMES': {
|
||||
# Default mappings
|
||||
'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
|
||||
#
|
||||
|
53
netbox/netbox/tests/test_config.py
Normal file
53
netbox/netbox/tests/test_config.py
Normal 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()
|
37
netbox/templates/admin/extras/configrevision/restore.html
Normal file
37
netbox/templates/admin/extras/configrevision/restore.html
Normal 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 %}
|
||||
|
||||
|
@ -58,13 +58,13 @@
|
||||
|
||||
</nav>
|
||||
|
||||
{% if settings.BANNER_TOP %}
|
||||
{% if config.BANNER_TOP %}
|
||||
<div class="alert alert-info text-center mx-3" role="alert">
|
||||
{{ settings.BANNER_TOP|safe }}
|
||||
{{ config.BANNER_TOP|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.MAINTENANCE_MODE %}
|
||||
{% if config.MAINTENANCE_MODE %}
|
||||
<div class="alert alert-warning text-center mx-3" role="alert">
|
||||
<h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
|
||||
<span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
|
||||
@ -98,9 +98,9 @@
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if settings.BANNER_BOTTOM %}
|
||||
{% if config.BANNER_BOTTOM %}
|
||||
<div class="alert alert-info text-center mx-3" role="alert">
|
||||
{{ settings.BANNER_BOTTOM|safe }}
|
||||
{{ config.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -100,7 +100,7 @@
|
||||
<td>
|
||||
{% if object.physical_address %}
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
@ -119,7 +119,7 @@
|
||||
<td>
|
||||
{% if object.latitude and object.longitude %}
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
@ -36,7 +36,7 @@
|
||||
{% endfor %}
|
||||
<div class="input-group input-group-sm">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
@ -7,9 +7,9 @@
|
||||
<main class="login-container text-center">
|
||||
|
||||
{# 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">
|
||||
{{ settings.BANNER_LOGIN|safe }}
|
||||
{{ config.BANNER_LOGIN|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
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.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.generic import View
|
||||
|
||||
from netbox.config import get_config
|
||||
from utilities.forms import ConfirmationForm
|
||||
from .forms import LoginForm, PasswordChangeForm, TokenForm
|
||||
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
|
||||
# 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")
|
||||
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
|
||||
|
||||
|
@ -1,8 +1,12 @@
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Paginator, Page
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
class EnhancedPaginator(Paginator):
|
||||
default_page_lengths = (
|
||||
25, 50, 100, 250, 500, 1000
|
||||
)
|
||||
|
||||
def __init__(self, object_list, per_page, orphans=None, **kwargs):
|
||||
|
||||
@ -10,9 +14,9 @@ class EnhancedPaginator(Paginator):
|
||||
try:
|
||||
per_page = int(per_page)
|
||||
if per_page < 1:
|
||||
per_page = settings.PAGINATE_COUNT
|
||||
per_page = get_config().PAGINATE_COUNT
|
||||
except ValueError:
|
||||
per_page = settings.PAGINATE_COUNT
|
||||
per_page = get_config().PAGINATE_COUNT
|
||||
|
||||
# Set orphans count based on page size
|
||||
if orphans is None and per_page <= 50:
|
||||
@ -25,6 +29,11 @@ class EnhancedPaginator(Paginator):
|
||||
def _get_page(self, *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):
|
||||
|
||||
@ -57,17 +66,19 @@ def get_paginate_count(request):
|
||||
|
||||
Return the lesser of the calculated value and MAX_PAGE_SIZE.
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
if 'per_page' in request.GET:
|
||||
try:
|
||||
per_page = int(request.GET.get('per_page'))
|
||||
if request.user.is_authenticated:
|
||||
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:
|
||||
pass
|
||||
|
||||
if request.user.is_authenticated:
|
||||
per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
|
||||
return min(per_page, settings.MAX_PAGE_SIZE)
|
||||
per_page = request.user.config.get('pagination.per_page', config.PAGINATE_COUNT)
|
||||
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)
|
||||
|
@ -14,6 +14,7 @@ from django.utils.html import strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
|
||||
from netbox.config import get_config
|
||||
from utilities.forms import get_selected_values, TableConfigForm
|
||||
from utilities.utils import foreground_color
|
||||
|
||||
@ -44,7 +45,7 @@ def render_markdown(value):
|
||||
value = strip_tags(value)
|
||||
|
||||
# Sanitize Markdown links
|
||||
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
|
||||
schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
|
||||
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
|
||||
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
@ -10,6 +9,7 @@ from dcim.models import Region, Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from ipam.models import VLAN
|
||||
from netbox.config import get_config
|
||||
from utilities.testing import APITestCase, disable_warnings
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ class APIPaginationTestCase(APITestCase):
|
||||
|
||||
def test_default_page_size(self):
|
||||
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.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
@ -1,9 +1,10 @@
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
class EnhancedURLValidator(URLValidator):
|
||||
"""
|
||||
@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator):
|
||||
r'(?::\d{2,5})?' # Port number
|
||||
r'(?:[/?#][^\s]*)?' # Path
|
||||
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):
|
||||
|
@ -1,4 +1,3 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
@ -340,7 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
|
||||
|
||||
@property
|
||||
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
|
||||
elif self.primary_ip6:
|
||||
return self.primary_ip6
|
||||
|
@ -17,8 +17,6 @@ __all__ = (
|
||||
'VMInterfaceTable',
|
||||
)
|
||||
|
||||
PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4')
|
||||
|
||||
VMINTERFACE_BUTTONS = """
|
||||
{% 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">
|
||||
@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable):
|
||||
)
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=PRIMARY_IP_ORDERING,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
tags = TagColumn(
|
||||
|
Loading…
Reference in New Issue
Block a user