diff --git a/base_requirements.txt b/base_requirements.txt
index f76019c27..6921f2d49 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -42,6 +42,10 @@ django-rich
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
django-rq
+# Provides a variety of storage backends
+# https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst
+django-storages
+
# Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
django-tables2
diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md
index 7cc4d3832..f702c3ffd 100644
--- a/docs/administration/replicating-netbox.md
+++ b/docs/administration/replicating-netbox.md
@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
!!! note
- These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
+ These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages).
### Archive the Media Directory
diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md
index f7e5d71ce..4a18e8a6c 100644
--- a/docs/configuration/required-parameters.md
+++ b/docs/configuration/required-parameters.md
@@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*']
## DATABASE
-NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
+!!! warning "Legacy Configuration Parameter"
+ The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases.
+
+See the [`DATABASES`](#databases) configuration below for usage.
+
+---
+
+## DATABASES
+
+!!! info "This parameter was introduced in NetBox v4.3."
+
+NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
+
+```python
+DATABASES = {
+ 'default': {...},
+ 'external1': {...},
+ 'external2': {...},
+}
+```
+
+NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.)
+
+The following parameters must be defined for each database:
* `NAME` - Database name
* `USER` - PostgreSQL username
@@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 13 or later database service to store dat
Example:
```python
-DATABASE = {
- 'ENGINE': 'django.db.backends.postgresql',
- 'NAME': 'netbox', # Database name
- 'USER': 'netbox', # PostgreSQL username
- 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql',
+ 'NAME': 'netbox', # Database name
+ 'USER': 'netbox', # PostgreSQL username
+ 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age
+ }
}
```
@@ -53,7 +78,7 @@ DATABASE = {
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
!!! warning
- Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql.
+ The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used.
---
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
index b97f31432..950d2df34 100644
--- a/docs/configuration/security.md
+++ b/docs/configuration/security.md
@@ -2,7 +2,10 @@
## ALLOW_TOKEN_RETRIEVAL
-Default: True
+Default: False
+
+!!! note
+ The default value of this parameter changed from true to false in NetBox v4.3.0.
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
@@ -186,6 +189,17 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
+## LOGIN_FORM_HIDDEN
+
+Default: False
+
+Option to hide the login form when only SSO authentication is in use.
+
+!!! warning
+ If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service.
+
+---
+
## LOGOUT_REDIRECT_URL
Default: `'home'`
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 81c1a6a94..11db09370 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -12,6 +12,14 @@ BASE_PATH = 'netbox/'
---
+## DATABASE_ROUTERS
+
+Default: `[]` (empty list)
+
+An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured.
+
+---
+
## DEFAULT_LANGUAGE
Default: `en-us` (US English)
@@ -196,23 +204,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
---
-## STORAGE_BACKEND
+## STORAGES
-Default: None (local storage)
+The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
-The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
+By default, the following configuration is used:
-The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
+```python
+STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
+ },
+ "scripts": {
+ "BACKEND": "extras.storage.ScriptFileSystemStorage",
+ },
+}
+```
----
+Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts.
-## STORAGE_CONFIG
+If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example:
-Default: Empty
+```python
+STORAGES = {
+ "scripts": {
+ "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
+ "OPTIONS": {
+ 'access_key': 'access key',
+ 'secret_key': 'secret key',
+ }
+ },
+}
+```
-A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
+The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html).
-If `STORAGE_BACKEND` is not defined, this setting will be ignored.
+!!! note
+ Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure.
---
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 1051b31f6..56dd08a76 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files:
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
+**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage.
+
## Logging
The Script object provides a set of convenient functions for recording messages at different severity levels:
diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md
index 0b77bfd4d..129bf2d4b 100644
--- a/docs/development/getting-started.md
+++ b/docs/development/getting-started.md
@@ -115,7 +115,7 @@ You may also need to set up the yarn packages as shown in the [Web UI Developmen
Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters:
* `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes
-* `DATABASE`: PostgreSQL database connection parameters
+* `DATABASES`: PostgreSQL database connection parameters
* `REDIS`: Redis configuration (if different from the defaults)
* `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key)
* `DEBUG`: Set to `True`
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 8ba302909..536ecea64 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
-!!! warning "PostgreSQL 13 or later required"
- NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported.
+!!! warning "PostgreSQL 14 or later required"
+ NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable --now postgresql
```
-Before continuing, verify that you have installed PostgreSQL 13 or later:
+Before continuing, verify that you have installed PostgreSQL 14 or later:
```no-highlight
psql -V
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 60d60d4f0..33eef6057 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -128,7 +128,7 @@ sudo cp configuration_example.py configuration.py
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
-* `DATABASE`
+* `DATABASES` (or `DATABASE`)
* `REDIS`
* `SECRET_KEY`
@@ -146,18 +146,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins
ALLOWED_HOSTS = ['*']
```
-### DATABASE
+### DATABASES
-This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters.
+This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins.
+
+A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters.
```python
-DATABASE = {
- 'NAME': 'netbox', # Database name
- 'USER': 'netbox', # PostgreSQL username
- 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age (seconds)
+DATABASES = {
+ 'default': {
+ 'NAME': 'netbox', # Database name
+ 'USER': 'netbox', # PostgreSQL username
+ 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age (seconds)
+ }
}
```
@@ -207,7 +211,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will
### Remote File Storage
-By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`.
+By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`.
```no-highlight
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
diff --git a/docs/installation/index.md b/docs/installation/index.md
index 33888e274..24e966805 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
-| PostgreSQL | 13+ |
+| PostgreSQL | 14+ |
| Redis | 4.0+ |
Below is a simplified overview of the NetBox application stack for reference:
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index e6d05738f..07250e780 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -20,7 +20,7 @@ NetBox requires the following dependencies:
| Dependency | Supported Versions |
|------------|--------------------|
| Python | 3.10, 3.11, 3.12 |
-| PostgreSQL | 13+ |
+| PostgreSQL | 14+ |
| Redis | 4.0+ |
## 3. Install the Latest Release
diff --git a/docs/introduction.md b/docs/introduction.md
index 75701c119..c8e5ee8ac 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
-| Database | PostgreSQL 13+ |
+| Database | PostgreSQL 14+ |
| Task queuing | Redis/django-rq |
diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md
index d2f9292c6..73be522b8 100644
--- a/docs/models/extras/exporttemplate.md
+++ b/docs/models/extras/exporttemplate.md
@@ -24,6 +24,12 @@ Jinja2 template code for rendering the exported data.
The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`.
+### File Name
+
+The file name to give to the rendered export file (optional).
+
+!!! info "This field was introduced in NetBox v4.3."
+
### File Extension
The file extension to append to the file name in the response (optional).
diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md
index 39de48261..c4bc91b5a 100644
--- a/docs/models/extras/tag.md
+++ b/docs/models/extras/tag.md
@@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
The color to use when displaying the tag in the NetBox UI.
+### Weight
+
+A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**.
+
+!!! info "This field was introduced in NetBox v4.3."
+
### Object Types
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md
index eac630180..f277ab499 100644
--- a/docs/models/tenancy/contact.md
+++ b/docs/models/tenancy/contact.md
@@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj
## Fields
-### Group
+### Groups
-The [contact group](./contactgroup.md) to which this contact is assigned (if any).
+The [contact groups](./contactgroup.md) to which this contact is assigned (if any).
+
+!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group."
### Name
diff --git a/netbox/account/views.py b/netbox/account/views.py
index 05f40df3f..3a2dc6b32 100644
--- a/netbox/account/views.py
+++ b/netbox/account/views.py
@@ -89,10 +89,12 @@ class LoginView(View):
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
+ login_form_hidden = settings.LOGIN_FORM_HIDDEN
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
+ 'login_form_hidden': login_form_hidden,
})
def post(self, request):
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
index 3f97fb003..9a5da333a 100644
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -357,17 +357,6 @@ class DataFile(models.Model):
return is_modified
- def write_to_disk(self, path, overwrite=False):
- """
- Write the object's data to disk at the specified path
- """
- # Check whether file already exists
- if os.path.isfile(path) and not overwrite:
- raise FileExistsError()
-
- with open(path, 'wb+') as new_file:
- new_file.write(self.data)
-
class AutoSyncRecord(models.Model):
"""
diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py
index cc446bac7..ade13627f 100644
--- a/netbox/core/models/files.py
+++ b/netbox/core/models/files.py
@@ -1,13 +1,16 @@
import logging
import os
+from functools import cached_property
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
+from django.core.files.storage import storages
from django.urls import reverse
from django.utils.translation import gettext as _
from ..choices import ManagedFileRootPathChoices
+from extras.storage import ScriptFileSystemStorage
from netbox.models.features import SyncedDataMixin
from utilities.querysets import RestrictedQuerySet
@@ -76,15 +79,35 @@ class ManagedFile(SyncedDataMixin, models.Model):
return os.path.join(self._resolve_root_path(), self.file_path)
def _resolve_root_path(self):
- return {
- 'scripts': settings.SCRIPTS_ROOT,
- 'reports': settings.REPORTS_ROOT,
- }[self.file_root]
+ storage = self.storage
+ if isinstance(storage, ScriptFileSystemStorage):
+ return {
+ 'scripts': settings.SCRIPTS_ROOT,
+ 'reports': settings.REPORTS_ROOT,
+ }[self.file_root]
+ else:
+ return ""
def sync_data(self):
if self.data_file:
self.file_path = os.path.basename(self.data_path)
- self.data_file.write_to_disk(self.full_path, overwrite=True)
+ self._write_to_disk(self.full_path, overwrite=True)
+
+ def _write_to_disk(self, path, overwrite=False):
+ """
+ Write the object's data to disk at the specified path
+ """
+ # Check whether file already exists
+ storage = self.storage
+ if storage.exists(path) and not overwrite:
+ raise FileExistsError()
+
+ with storage.open(path, 'wb+') as new_file:
+ new_file.write(self.data)
+
+ @cached_property
+ def storage(self):
+ return storages.create_storage(storages.backends["scripts"])
def clean(self):
super().clean()
@@ -104,8 +127,9 @@ class ManagedFile(SyncedDataMixin, models.Model):
def delete(self, *args, **kwargs):
# Delete file from disk
+ storage = self.storage
try:
- os.remove(self.full_path)
+ storage.delete(self.full_path)
except FileNotFoundError:
pass
diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py
index b818cd954..90f7b5d35 100644
--- a/netbox/dcim/api/serializers_/sites.py
+++ b/netbox/dcim/api/serializers_/sites.py
@@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer):
model = Region
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
- 'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
+ 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
model = SiteGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
- 'created', 'last_updated', 'site_count', 'prefix_count', '_depth',
+ 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
@@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count',
- 'prefix_count', '_depth',
+ 'prefix_count', 'comments', '_depth',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index e46730da8..6f9f481c3 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices
from netbox.filtersets import (
- BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
+ BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
+ OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import *
@@ -81,7 +82,7 @@ __all__ = (
)
-class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
+class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
label=_('Parent region (ID)'),
@@ -111,7 +112,7 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
fields = ('id', 'name', 'slug', 'description')
-class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
+class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
label=_('Parent site group (ID)'),
@@ -205,7 +206,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
return queryset.filter(qs_filter).distinct()
-class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
+class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
@@ -275,13 +276,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
fields = ('id', 'name', 'slug', 'facility', 'description')
def search(self, queryset, name, value):
- if not value.strip():
- return queryset
- return queryset.filter(
- Q(name__icontains=value) |
- Q(facility__icontains=value) |
- Q(description__icontains=value)
- )
+ # extended in order to include querying on Location.facility
+ queryset = super().search(queryset, name, value)
+
+ if value.strip():
+ queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
+
+ return queryset
class RackRoleFilterSet(OrganizationalModelFilterSet):
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 3b9a183cd..c1da9c8d1 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -78,12 +78,13 @@ class RegionBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = Region
fieldsets = (
FieldSet('parent', 'description'),
)
- nullable_fields = ('parent', 'description')
+ nullable_fields = ('parent', 'description', 'comments')
class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
@@ -97,12 +98,13 @@ class SiteGroupBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = SiteGroup
fieldsets = (
FieldSet('parent', 'description'),
)
- nullable_fields = ('parent', 'description')
+ nullable_fields = ('parent', 'description', 'comments')
class SiteBulkEditForm(NetBoxModelBulkEditForm):
@@ -197,12 +199,13 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm):
max_length=200,
required=False
)
+ comments = CommentField()
model = Location
fieldsets = (
FieldSet('site', 'parent', 'status', 'tenant', 'description'),
)
- nullable_fields = ('parent', 'tenant', 'description')
+ nullable_fields = ('parent', 'tenant', 'description', 'comments')
class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index 92f7220da..469e40217 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -68,7 +68,7 @@ class RegionImportForm(NetBoxModelImportForm):
class Meta:
model = Region
- fields = ('name', 'slug', 'parent', 'description', 'tags')
+ fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
class SiteGroupImportForm(NetBoxModelImportForm):
@@ -82,7 +82,7 @@ class SiteGroupImportForm(NetBoxModelImportForm):
class Meta:
model = SiteGroup
- fields = ('name', 'slug', 'parent', 'description')
+ fields = ('name', 'slug', 'parent', 'description', 'comments', 'tags')
class SiteImportForm(NetBoxModelImportForm):
@@ -160,7 +160,10 @@ class LocationImportForm(NetBoxModelImportForm):
class Meta:
model = Location
- fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description', 'tags')
+ fields = (
+ 'site', 'parent', 'name', 'slug', 'status', 'tenant', 'facility', 'description',
+ 'tags', 'comments',
+ )
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 91e23e8b1..dea031b64 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -78,6 +78,7 @@ class RegionForm(NetBoxModelForm):
required=False
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@@ -86,7 +87,7 @@ class RegionForm(NetBoxModelForm):
class Meta:
model = Region
fields = (
- 'parent', 'name', 'slug', 'description', 'tags',
+ 'parent', 'name', 'slug', 'description', 'tags', 'comments',
)
@@ -97,6 +98,7 @@ class SiteGroupForm(NetBoxModelForm):
required=False
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('parent', 'name', 'slug', 'description', 'tags'),
@@ -105,7 +107,7 @@ class SiteGroupForm(NetBoxModelForm):
class Meta:
model = SiteGroup
fields = (
- 'parent', 'name', 'slug', 'description', 'tags',
+ 'parent', 'name', 'slug', 'description', 'comments', 'tags',
)
@@ -179,6 +181,7 @@ class LocationForm(TenancyForm, NetBoxModelForm):
}
)
slug = SlugField()
+ comments = CommentField()
fieldsets = (
FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')),
@@ -188,7 +191,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Location
fields = (
- 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'facility', 'tags',
+ 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
+ 'facility', 'tags', 'comments',
)
diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py
new file mode 100644
index 000000000..ffdc5ba8a
--- /dev/null
+++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0201_add_power_outlet_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='location',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='region',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='sitegroup',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ ]
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
index 5dea2a09b..a85005679 100644
--- a/netbox/dcim/search.py
+++ b/netbox/dcim/search.py
@@ -144,6 +144,7 @@ class LocationIndex(SearchIndex):
('facility', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('site', 'status', 'tenant', 'facility', 'description')
@@ -317,6 +318,7 @@ class RegionIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('parent', 'description')
@@ -343,6 +345,7 @@ class SiteGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
+ ('comments', 5000),
)
display_attrs = ('parent', 'description')
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index 77844f086..7d2f0e0cc 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -32,12 +32,15 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:region_list'
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = Region
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -59,12 +62,15 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = SiteGroup
fields = (
- 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
@@ -153,12 +159,15 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
actions = columns.ActionsColumn(
extra_buttons=LOCATION_BUTTONS
)
+ comments = columns.MarkdownColumn(
+ verbose_name=_('Comments'),
+ )
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
- 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
+ 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 08f93f6ea..807ac77d4 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -74,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Region 4',
'slug': 'region-4',
+ 'comments': 'this is region 4, not region 5',
},
{
'name': 'Region 5',
@@ -86,13 +87,14 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
]
bulk_update_data = {
'description': 'New description',
+ 'comments': 'New comments',
}
@classmethod
def setUpTestData(cls):
Region.objects.create(name='Region 1', slug='region-1')
- Region.objects.create(name='Region 2', slug='region-2')
+ Region.objects.create(name='Region 2', slug='region-2', comments='what in the world is happening?')
Region.objects.create(name='Region 3', slug='region-3')
@@ -103,26 +105,30 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Site Group 4',
'slug': 'site-group-4',
+ 'comments': '',
},
{
'name': 'Site Group 5',
'slug': 'site-group-5',
+ 'comments': 'not actually empty',
},
{
'name': 'Site Group 6',
'slug': 'site-group-6',
+ 'comments': 'Do I really exist?',
},
]
bulk_update_data = {
'description': 'New description',
+ 'comments': 'I do exist!',
}
@classmethod
def setUpTestData(cls):
SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
- SiteGroup.objects.create(name='Site Group 2', slug='site-group-2')
- SiteGroup.objects.create(name='Site Group 3', slug='site-group-3')
+ SiteGroup.objects.create(name='Site Group 2', slug='site-group-2', comments='')
+ SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
class SiteTest(APIViewTestCases.APIViewTestCase):
@@ -212,12 +218,14 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
name='Parent Location 1',
slug='parent-location-1',
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='First!'
),
Location.objects.create(
site=sites[1],
name='Parent Location 2',
slug='parent-location-2',
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='Second!'
),
)
@@ -227,6 +235,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
slug='location-1',
parent=parent_locations[0],
status=LocationStatusChoices.STATUS_ACTIVE,
+ comments='Third!'
)
Location.objects.create(
site=sites[0],
@@ -250,6 +259,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
+ 'comments': '',
},
{
'name': 'Test Location 5',
@@ -257,6 +267,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'site': sites[1].pk,
'parent': parent_locations[1].pk,
'status': LocationStatusChoices.STATUS_PLANNED,
+ 'comments': 'Somebody should check on this location',
},
{
'name': 'Test Location 6',
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 7c9b8adc6..0c4bbbaff 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls):
parent_regions = (
- Region(name='Region 1', slug='region-1', description='foobar1'),
- Region(name='Region 2', slug='region-2', description='foobar2'),
- Region(name='Region 3', slug='region-3', description='foobar3'),
+ Region(
+ name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that",
+ ),
+ Region(
+ name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more',
+ ),
+ Region(
+ name='Region 3', slug='region-3', description='foobar3', comments='could ever do'
+ ),
)
for region in parent_regions:
region.save()
@@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'there'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'q': 'hundred men could'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
def test_name(self):
params = {'name': ['Region 1', 'Region 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -148,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]),
SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]),
SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]),
- SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]),
+ SiteGroup(
+ name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group',
+ ),
)
for site_group in groups:
site_group.save()
child_groups = (
- SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]),
+ SiteGroup(
+ name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group',
+ ),
SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]),
SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]),
SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]),
@@ -168,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'this'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ params = {'q': 'child'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Site Group 1', 'Site Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -401,6 +425,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_PLANNED,
facility='Facility 1',
description='foobar1',
+ comments='',
),
Location(
name='Location 2A',
@@ -410,6 +435,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_STAGING,
facility='Facility 2',
description='foobar2',
+ comments='First comment!',
),
Location(
name='Location 3A',
@@ -419,6 +445,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LocationStatusChoices.STATUS_DECOMMISSIONING,
facility='Facility 3',
description='foobar3',
+ comments='_This_ is a **bold comment**',
),
)
for location in locations:
@@ -436,6 +463,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_q_comments(self):
+ params = {'q': 'this'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'q': 'comment'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_name(self):
params = {'name': ['Location 1', 'Location 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 4dea94c7d..83effa188 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -25,8 +25,10 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three Regions
regions = (
- Region(name='Region 1', slug='region-1'),
- Region(name='Region 2', slug='region-2'),
+ Region(name='Region 1', slug='region-1', comments=''),
+ Region(
+ name='Region 2', slug='region-2', comments="It's going to take a lot to drag me away from you"
+ ),
Region(name='Region 3', slug='region-3'),
)
for region in regions:
@@ -40,13 +42,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': regions[2].pk,
'description': 'A new region',
'tags': [t.pk for t in tags],
+ 'comments': 'This comment is really exciting!',
}
cls.csv_data = (
- "name,slug,description",
- "Region 4,region-4,Fourth region",
- "Region 5,region-5,Fifth region",
- "Region 6,region-6,Sixth region",
+ "name,slug,description,comments",
+ "Region 4,region-4,Fourth region,",
+ "Region 5,region-5,Fifth region,hi guys",
+ "Region 6,region-6,Sixth region,bye guys",
)
cls.csv_update_data = (
@@ -58,6 +61,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'This comment is super exciting!!!',
}
@@ -69,7 +73,7 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
# Create three SiteGroups
sitegroups = (
- SiteGroup(name='Site Group 1', slug='site-group-1'),
+ SiteGroup(name='Site Group 1', slug='site-group-1', comments='Still here'),
SiteGroup(name='Site Group 2', slug='site-group-2'),
SiteGroup(name='Site Group 3', slug='site-group-3'),
)
@@ -84,24 +88,26 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'parent': sitegroups[2].pk,
'description': 'A new site group',
'tags': [t.pk for t in tags],
+ 'comments': 'still here',
}
cls.csv_data = (
- "name,slug,description",
- "Site Group 4,site-group-4,Fourth site group",
- "Site Group 5,site-group-5,Fifth site group",
- "Site Group 6,site-group-6,Sixth site group",
+ "name,slug,description,comments",
+ "Site Group 4,site-group-4,Fourth site group,",
+ "Site Group 5,site-group-5,Fifth site group,still hear",
+ "Site Group 6,site-group-6,Sixth site group,"
)
cls.csv_update_data = (
- "id,name,description",
- f"{sitegroups[0].pk},Site Group 7,Fourth site group7",
- f"{sitegroups[1].pk},Site Group 8,Fifth site group8",
- f"{sitegroups[2].pk},Site Group 0,Sixth site group9",
+ "id,name,description,comments",
+ f"{sitegroups[0].pk},Site Group 7,Fourth site group7,",
+ f"{sitegroups[1].pk},Site Group 8,Fifth site group8,when will it end",
+ f"{sitegroups[2].pk},Site Group 0,Sixth site group9,",
)
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'the end',
}
@@ -202,6 +208,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='',
),
Location(
name='Location 2',
@@ -209,6 +216,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='First comment!',
),
Location(
name='Location 3',
@@ -216,6 +224,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
site=site,
status=LocationStatusChoices.STATUS_ACTIVE,
tenant=tenant,
+ comments='_This_ is a **bold comment**',
),
)
for location in locations:
@@ -232,24 +241,26 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tenant': tenant.pk,
'description': 'A new location',
'tags': [t.pk for t in tags],
+ 'comments': 'This comment is really boring',
}
cls.csv_data = (
- "site,tenant,name,slug,status,description",
- "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location",
- "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location",
- "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
+ "site,tenant,name,slug,status,description,comments",
+ "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location,",
+ "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location,",
+ "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location,hi!",
)
cls.csv_update_data = (
- "id,name,description",
- f"{locations[0].pk},Location 7,Fourth location7",
- f"{locations[1].pk},Location 8,Fifth location8",
- f"{locations[2].pk},Location 0,Sixth location9",
+ "id,name,description,comments",
+ f"{locations[0].pk},Location 7,Fourth location7,Useful comment",
+ f"{locations[1].pk},Location 8,Fifth location8,unuseful comment",
+ f"{locations[2].pk},Location 0,Sixth location9,",
)
cls.bulk_edit_data = {
'description': 'New description',
+ 'comments': 'This comment is also really boring',
}
diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py
index 11f502a02..ad77cd1f7 100644
--- a/netbox/extras/api/serializers_/exporttemplates.py
+++ b/netbox/extras/api/serializers_/exporttemplates.py
@@ -27,7 +27,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type',
- 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
- 'last_updated',
+ 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced',
+ 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py
index ea964ff52..5dc39584f 100644
--- a/netbox/extras/api/serializers_/tags.py
+++ b/netbox/extras/api/serializers_/tags.py
@@ -27,8 +27,8 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = [
- 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types',
- 'tagged_items', 'created', 'last_updated',
+ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight',
+ 'object_types', 'tagged_items', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 98302d0f4..e63b6d673 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -258,8 +258,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = ExportTemplate
fields = (
- 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled',
- 'data_synced',
+ 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'auto_sync_enabled', 'data_synced',
)
def search(self, queryset, name, value):
@@ -267,7 +267,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
- Q(description__icontains=value)
+ Q(description__icontains=value) |
+ Q(file_name__icontains=value)
)
@@ -450,7 +451,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta:
model = Tag
- fields = ('id', 'name', 'slug', 'color', 'description', 'object_types')
+ fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types')
def search(self, queryset, name, value):
if not value.strip():
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index 30d06683b..6891edc5d 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm):
max_length=50,
required=False
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
max_length=15,
@@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
- nullable_fields = ('description', 'mime_type', 'file_extension')
+ nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
class SavedFilterBulkEditForm(BulkEditForm):
@@ -275,6 +279,10 @@ class TagBulkEditForm(BulkEditForm):
max_length=200,
required=False
)
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
nullable_fields = ('description',)
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 655a5d6ca..fb522bd7b 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm):
class Meta:
model = ExportTemplate
fields = (
- 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code',
+ 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
+ 'template_code',
)
@@ -232,10 +233,14 @@ class EventRuleImportForm(NetBoxModelImportForm):
class TagImportForm(CSVModelForm):
slug = SlugField()
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
class Meta:
model = Tag
- fields = ('name', 'slug', 'color', 'description')
+ fields = ('name', 'slug', 'color', 'weight', 'description')
class JournalEntryImportForm(NetBoxModelImportForm):
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 05dcf96c4..1691559f9 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -162,7 +162,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
- FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')),
+ FieldSet('object_type_id', 'mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Attributes')),
)
data_source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
required=False,
label=_('MIME type')
)
+ file_name = forms.CharField(
+ label=_('File name'),
+ required=False
+ )
file_extension = forms.CharField(
label=_('File extension'),
required=False
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index a45daaf70..b5bc06b40 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -246,7 +246,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm):
fieldsets = (
FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')),
FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')),
- FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')),
+ FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')),
)
class Meta:
@@ -490,15 +490,19 @@ class TagForm(forms.ModelForm):
queryset=ObjectType.objects.with_feature('tags'),
required=False
)
+ weight = forms.IntegerField(
+ label=_('Weight'),
+ required=False
+ )
fieldsets = (
- FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')),
+ FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')),
)
class Meta:
model = Tag
fields = [
- 'name', 'slug', 'color', 'description', 'object_types',
+ 'name', 'slug', 'color', 'weight', 'description', 'object_types',
]
diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py
index 331f7f01f..764246a2d 100644
--- a/netbox/extras/forms/scripts.py
+++ b/netbox/extras/forms/scripts.py
@@ -1,11 +1,18 @@
+import os
+
from django import forms
+from django.conf import settings
+from django.core.files.storage import storages
from django.utils.translation import gettext_lazy as _
+from core.forms import ManagedFileForm
from extras.choices import DurationChoices
+from extras.storage import ScriptFileSystemStorage
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
from utilities.datetime import local_now
__all__ = (
+ 'ScriptFileForm',
'ScriptForm',
)
@@ -55,3 +62,26 @@ class ScriptForm(forms.Form):
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data
+
+
+class ScriptFileForm(ManagedFileForm):
+ """
+ ManagedFileForm with a custom save method to use django-storages.
+ """
+ def save(self, *args, **kwargs):
+ # If a file was uploaded, save it to disk
+ if self.cleaned_data['upload_file']:
+ storage = storages.create_storage(storages.backends["scripts"])
+
+ filename = self.cleaned_data['upload_file'].name
+ if isinstance(storage, ScriptFileSystemStorage):
+ full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
+ else:
+ full_path = filename
+
+ self.instance.file_path = full_path
+ data = self.cleaned_data['upload_file']
+ storage.save(filename, data)
+
+ # need to skip ManagedFileForm save method
+ return super(ManagedFileForm, self).save(*args, **kwargs)
diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py
new file mode 100644
index 000000000..759ad1595
--- /dev/null
+++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0123_remove_staging'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='tag',
+ options={'ordering': ('weight', 'name')},
+ ),
+ migrations.AddField(
+ model_name='tag',
+ name='weight',
+ field=models.PositiveSmallIntegerField(default=0),
+ ),
+ ]
diff --git a/netbox/extras/migrations/0125_exporttemplate_file_name.py b/netbox/extras/migrations/0125_exporttemplate_file_name.py
new file mode 100644
index 000000000..2c8ac118b
--- /dev/null
+++ b/netbox/extras/migrations/0125_exporttemplate_file_name.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0124_alter_tag_options_tag_weight'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='exporttemplate',
+ name='file_name',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py
index 0950324c8..f22b32004 100644
--- a/netbox/extras/models/mixins.py
+++ b/netbox/extras/models/mixins.py
@@ -1,11 +1,31 @@
+import importlib.abc
+import importlib.util
import os
-from importlib.machinery import SourceFileLoader
+import sys
+from django.core.files.storage import storages
__all__ = (
'PythonModuleMixin',
)
+class CustomStoragesLoader(importlib.abc.Loader):
+ """
+ Custom loader for exec_module to use django-storages instead of the file system.
+ """
+ def __init__(self, filename):
+ self.filename = filename
+
+ def create_module(self, spec):
+ return None # Use default module creation
+
+ def exec_module(self, module):
+ storage = storages.create_storage(storages.backends["scripts"])
+ with storage.open(self.filename, 'rb') as f:
+ code = f.read()
+ exec(code, module.__dict__)
+
+
class PythonModuleMixin:
def get_jobs(self, name):
@@ -33,6 +53,16 @@ class PythonModuleMixin:
return name
def get_module(self):
- loader = SourceFileLoader(self.python_name, self.full_path)
- module = loader.load_module()
+ """
+ Load the module using importlib, but use a custom loader to use django-storages
+ instead of the file system.
+ """
+ spec = importlib.util.spec_from_file_location(self.python_name, self.name)
+ if spec is None:
+ raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
+ loader = CustomStoragesLoader(self.name)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[self.python_name] = module
+ loader.exec_module(module)
+
return module
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index d3e443b14..3cae54f29 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -16,7 +16,7 @@ from core.models import ObjectType
from extras.choices import *
from extras.conditions import ConditionSet
from extras.constants import *
-from extras.utils import image_upload
+from extras.utils import filename_from_model, image_upload
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
@@ -409,6 +409,11 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
verbose_name=_('MIME type'),
help_text=_('Defaults to text/plain; charset=utf-8
')
)
+ file_name = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text=_('Filename to give to the rendered export file')
+ )
file_extension = models.CharField(
verbose_name=_('file extension'),
max_length=15,
@@ -422,7 +427,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
)
clone_fields = (
- 'object_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
+ 'object_types', 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment',
)
class Meta:
@@ -480,10 +485,10 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
response = HttpResponse(output, content_type=mime_type)
if self.as_attachment:
- basename = queryset.model._meta.verbose_name_plural.replace(' ', '_')
extension = f'.{self.file_extension}' if self.file_extension else ''
- filename = f'netbox_{basename}{extension}'
- response['Content-Disposition'] = f'attachment; filename="{filename}"'
+ filename = self.file_name or filename_from_model(queryset.model)
+ full_filename = f'{filename}{extension}'
+ response['Content-Disposition'] = f'attachment; filename="{full_filename}"'
return response
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index baf72baa1..4c6396172 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -40,13 +40,17 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True,
help_text=_("The object type(s) to which this tag can be applied.")
)
+ weight = models.PositiveSmallIntegerField(
+ verbose_name=_('weight'),
+ default=0,
+ )
clone_fields = (
'color', 'description', 'object_types',
)
class Meta:
- ordering = ['name']
+ ordering = ('weight', 'name')
verbose_name = _('tag')
verbose_name_plural = _('tags')
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index f2bd75a1d..83195402d 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -2,10 +2,12 @@ import inspect
import json
import logging
import os
+import re
import yaml
from django import forms
from django.conf import settings
+from django.core.files.storage import storages
from django.core.validators import RegexValidator
from django.utils import timezone
from django.utils.functional import classproperty
@@ -367,9 +369,46 @@ class BaseScript:
def filename(self):
return inspect.getfile(self.__class__)
+ def findsource(self, object):
+ storage = storages.create_storage(storages.backends["scripts"])
+ with storage.open(os.path.basename(self.filename), 'r') as f:
+ data = f.read()
+
+ # Break the source code into lines
+ lines = [line + '\n' for line in data.splitlines()]
+
+ # Find the class definition
+ name = object.__name__
+ pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
+ # use the class definition with the least indentation
+ candidates = []
+ for i in range(len(lines)):
+ match = pat.match(lines[i])
+ if match:
+ if lines[i][0] == 'c':
+ return lines, i
+
+ candidates.append((match.group(1), i))
+ if not candidates:
+ raise OSError('could not find class definition')
+
+ # Sort the candidates by whitespace, and by line number
+ candidates.sort()
+ return lines, candidates[0][1]
+
@property
def source(self):
- return inspect.getsource(self.__class__)
+ # Can't use inspect.getsource() as it uses os to get the file
+ # inspect uses ast, but that is overkill for this as we only do
+ # classes.
+ object = self.__class__
+
+ try:
+ lines, lnum = self.findsource(object)
+ lines = inspect.getblock(lines[lnum:])
+ return ''.join(lines)
+ except OSError:
+ return ''
@classmethod
def _get_vars(cls):
@@ -524,7 +563,12 @@ class BaseScript:
def load_yaml(self, filename):
"""
Return data from a YAML file
+ TODO: DEPRECATED: Remove this method in v4.4
"""
+ self._log(
+ _("load_yaml is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
try:
from yaml import CLoader as Loader
except ImportError:
@@ -539,7 +583,12 @@ class BaseScript:
def load_json(self, filename):
"""
Return data from a JSON file
+ TODO: DEPRECATED: Remove this method in v4.4
"""
+ self._log(
+ _("load_json is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
@@ -555,7 +604,6 @@ class BaseScript:
Run the report and save its results. Each test method will be executed in order.
"""
self.logger.info("Running report")
-
try:
for test_name in self.tests:
self._current_test = test_name
diff --git a/netbox/extras/storage.py b/netbox/extras/storage.py
new file mode 100644
index 000000000..ede4fac7f
--- /dev/null
+++ b/netbox/extras/storage.py
@@ -0,0 +1,14 @@
+from django.conf import settings
+from django.core.files.storage import FileSystemStorage
+from django.utils.functional import cached_property
+
+
+class ScriptFileSystemStorage(FileSystemStorage):
+ """
+ Custom storage for scripts - for django-storages as the default one will
+ go off media-root and raise security errors as the scripts can be outside
+ the media-root directory.
+ """
+ @cached_property
+ def base_location(self):
+ return settings.SCRIPTS_ROOT
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index e538c488e..7a6e79cab 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -203,11 +203,12 @@ class ExportTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ExportTemplate
fields = (
- 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment',
- 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'is_synced',
+ 'pk', 'name', 'object_types', 'description', 'mime_type', 'file_name', 'file_extension',
+ 'as_attachment', 'is_synced',
)
@@ -449,8 +450,8 @@ class TagTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Tag
fields = (
- 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated',
- 'actions',
+ 'pk', 'id', 'name', 'items', 'slug', 'color', 'weight', 'description', 'object_types',
+ 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 17f03350d..7a4d63549 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'],
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ 'file_name': 'test_export_template_6',
},
]
bulk_update_data = {
@@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
ExportTemplate(
name='Export Template 2',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ file_name='export_template_2',
+ file_extension='test',
),
ExportTemplate(
name='Export Template 3',
@@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
)
ExportTemplate.objects.bulk_create(export_templates)
+
+ device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates:
- et.object_types.set([ObjectType.objects.get_for_model(Device)])
+ et.object_types.set([device_object_type])
class TagTest(APIViewTestCases.APIViewTestCase):
@@ -513,6 +518,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Tag 4',
'slug': 'tag-4',
+ 'weight': 1000,
},
{
'name': 'Tag 5',
@@ -533,7 +539,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
tags = (
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
- Tag(name='Tag 3', slug='tag-3'),
+ Tag(name='Tag 3', slug='tag-3', weight=26),
)
Tag.objects.bulk_create(tags)
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index 9684b3dbe..ff4543bd2 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -624,8 +624,11 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
export_templates = (
ExportTemplate(name='Export Template 1', template_code='TESTING', description='foobar1'),
- ExportTemplate(name='Export Template 2', template_code='TESTING', description='foobar2'),
- ExportTemplate(name='Export Template 3', template_code='TESTING'),
+ ExportTemplate(
+ name='Export Template 2', template_code='TESTING', description='foobar2',
+ file_name='export_template_2', file_extension='nagios',
+ ),
+ ExportTemplate(name='Export Template 3', template_code='TESTING', file_name='export_filename'),
)
ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates):
@@ -635,6 +638,9 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'q': 'export_filename'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
def test_name(self):
params = {'name': ['Export Template 1', 'Export Template 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -649,6 +655,20 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_file_name(self):
+ params = {'file_name': ['export_filename']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_file_extension(self):
+ params = {'file_extension': ['nagios']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'file_name': ['export_template_2'], 'file_extension': ['nagios']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'file_name': 'export_filename', 'file_extension': ['nagios']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all()
@@ -1196,7 +1216,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
- Tag(name='Tag 3', slug='tag-3', color='0000ff'),
+ Tag(name='Tag 3', slug='tag-3', color='0000ff', weight=1000),
)
Tag.objects.bulk_create(tags)
tags[0].object_types.add(object_types['site'])
@@ -1249,6 +1269,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
['Tag 2', 'Tag 3']
)
+ def test_weight(self):
+ params = {'weight': [1000]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ params = {'weight': [0]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
class TaggedItemFilterSetTestCase(TestCase):
queryset = TaggedItem.objects.all()
diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py
index c90390dd1..bf05a8c18 100644
--- a/netbox/extras/tests/test_models.py
+++ b/netbox/extras/tests/test_models.py
@@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class TagTest(TestCase):
+ def test_default_ordering_weight_then_name_is_set(self):
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=100)
+ Tag.objects.create(name='Tag 2', slug='tag-2')
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=10)
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=10)
+
+ tags = Tag.objects.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
+ def test_tag_related_manager_ordering_weight_then_name(self):
+ tags = [
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=100),
+ Tag.objects.create(name='Tag 2', slug='tag-2'),
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=10),
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=10),
+ ]
+
+ site = Site.objects.create(name='Site 1')
+ for tag in tags:
+ site.tags.add(tag)
+ site.save()
+
+ site = Site.objects.first()
+ tags = site.tags.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣')
tag.save()
diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py
new file mode 100644
index 000000000..b851acab8
--- /dev/null
+++ b/netbox/extras/tests/test_utils.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+
+from extras.models import ExportTemplate
+from extras.utils import filename_from_model
+from tenancy.models import ContactGroup, TenantGroup
+from wireless.models import WirelessLANGroup
+
+
+class FilenameFromModelTests(TestCase):
+ def test_expected_output(self):
+ cases = (
+ (ExportTemplate, 'netbox_export_templates'),
+ (ContactGroup, 'netbox_contact_groups'),
+ (TenantGroup, 'netbox_tenant_groups'),
+ (WirelessLANGroup, 'netbox_wireless_lan_groups'),
+ )
+
+ for model, expected in cases:
+ self.assertEqual(filename_from_model(model), expected)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 5d82fae4c..0688cd2c2 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -305,7 +305,7 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
export_templates = (
ExportTemplate(name='Export Template 1', template_code=TEMPLATE_CODE),
ExportTemplate(name='Export Template 2', template_code=TEMPLATE_CODE),
- ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE),
+ ExportTemplate(name='Export Template 3', template_code=TEMPLATE_CODE, file_name='export_template_3')
)
ExportTemplate.objects.bulk_create(export_templates)
for et in export_templates:
@@ -315,13 +315,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X',
'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE,
+ 'file_name': 'template_x',
}
cls.csv_data = (
- "name,object_types,template_code",
- f"Export Template 4,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 5,dcim.site,{TEMPLATE_CODE}",
- f"Export Template 6,dcim.site,{TEMPLATE_CODE}",
+ "name,object_types,template_code,file_name",
+ f"Export Template 4,dcim.site,{TEMPLATE_CODE},",
+ f"Export Template 5,dcim.site,{TEMPLATE_CODE},template_5",
+ f"Export Template 6,dcim.site,{TEMPLATE_CODE},",
)
cls.csv_update_data = (
@@ -441,8 +442,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
tags = (
Tag(name='Tag 1', slug='tag-1'),
- Tag(name='Tag 2', slug='tag-2'),
- Tag(name='Tag 3', slug='tag-3'),
+ Tag(name='Tag 2', slug='tag-2', weight=1),
+ Tag(name='Tag 3', slug='tag-3', weight=32767),
)
Tag.objects.bulk_create(tags)
@@ -451,13 +452,14 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'slug': 'tag-x',
'color': 'c0c0c0',
'comments': 'Some comments',
+ 'weight': 11,
}
cls.csv_data = (
- "name,slug,color,description",
- "Tag 4,tag-4,ff0000,Fourth tag",
- "Tag 5,tag-5,00ff00,Fifth tag",
- "Tag 6,tag-6,0000ff,Sixth tag",
+ "name,slug,color,description,weight",
+ "Tag 4,tag-4,ff0000,Fourth tag,0",
+ "Tag 5,tag-5,00ff00,Fifth tag,1111",
+ "Tag 6,tag-6,0000ff,Sixth tag,0",
)
cls.csv_update_data = (
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index efe7ada5b..411d80f78 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -1,6 +1,7 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
+from django.db import models
from taggit.managers import _TaggableManager
from netbox.context import current_request
@@ -15,6 +16,12 @@ __all__ = (
)
+def filename_from_model(model: models.Model) -> str:
+ """Standardises how we generate filenames from model class for exports"""
+ base = model._meta.verbose_name_plural.lower().replace(' ', '_')
+ return f'netbox_{base}'
+
+
def is_taggable(obj):
"""
Return True if the instance can have Tags assigned to it; False otherwise.
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 9cb9dd54a..2833cec0d 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -12,7 +12,6 @@ from django.utils.translation import gettext as _
from django.views.generic import View
from core.choices import ManagedFileRootPathChoices
-from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
@@ -1163,7 +1162,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
@register_model_view(ScriptModule, 'edit')
class ScriptModuleCreateView(generic.ObjectEditView):
queryset = ScriptModule.objects.all()
- form = ManagedFileForm
+ form = forms.ScriptFileForm
def alter_object(self, obj, *args, **kwargs):
obj.file_root = ManagedFileRootPathChoices.SCRIPTS
diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py
index 84ead5339..27d44a2ff 100644
--- a/netbox/netbox/configuration_example.py
+++ b/netbox/netbox/configuration_example.py
@@ -12,14 +12,16 @@ ALLOWED_HOSTS = []
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
-DATABASE = {
- 'ENGINE': 'django.db.backends.postgresql', # Database engine
- 'NAME': 'netbox', # Database name
- 'USER': '', # PostgreSQL username
- 'PASSWORD': '', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql', # Database engine
+ 'NAME': 'netbox', # Database name
+ 'USER': '', # PostgreSQL username
+ 'PASSWORD': '', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age
+ }
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
@@ -164,6 +166,9 @@ LOGIN_REQUIRED = True
# re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None
+# Hide the login form. Useful when only allowing SSO authentication.
+LOGIN_FORM_HIDDEN = False
+
# The view name or URL to which users are redirected after logging out.
LOGOUT_REDIRECT_URL = 'home'
diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py
index cec05cabb..52973e94d 100644
--- a/netbox/netbox/configuration_testing.py
+++ b/netbox/netbox/configuration_testing.py
@@ -5,13 +5,15 @@
ALLOWED_HOSTS = ['*']
-DATABASE = {
- 'NAME': 'netbox',
- 'USER': 'netbox',
- 'PASSWORD': 'netbox',
- 'HOST': 'localhost',
- 'PORT': '',
- 'CONN_MAX_AGE': 300,
+DATABASES = {
+ 'default': {
+ 'NAME': 'netbox',
+ 'USER': 'netbox',
+ 'PASSWORD': 'netbox',
+ 'HOST': 'localhost',
+ 'PORT': '',
+ 'CONN_MAX_AGE': 300,
+ }
}
PLUGINS = [
@@ -41,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {}
+ALLOW_TOKEN_RETRIEVAL = True
+
LOGGING = {
'version': 1,
'disable_existing_loggers': True
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
index b8fbe7ad5..d80b07e90 100644
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -329,3 +329,19 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet):
models.Q(slug__icontains=value) |
models.Q(description__icontains=value)
)
+
+
+class NestedGroupModelFilterSet(NetBoxModelFilterSet):
+ """
+ A base FilterSet for models that inherit from NestedGroupModel
+ """
+ def search(self, queryset, name, value):
+ if value.strip():
+ queryset = queryset.filter(
+ models.Q(name__icontains=value) |
+ models.Q(slug__icontains=value) |
+ models.Q(description__icontains=value) |
+ models.Q(comments__icontains=value)
+ )
+
+ return queryset
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index b1f7cfd48..3ad0ac556 100644
--- a/netbox/netbox/models/__init__.py
+++ b/netbox/netbox/models/__init__.py
@@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
max_length=200,
blank=True
)
+ comments = models.TextField(
+ verbose_name=_('comments'),
+ blank=True
+ )
objects = TreeManager()
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index e58037b85..a2fb8d615 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -455,7 +455,8 @@ class TagsMixin(models.Model):
which is a `TaggableManager` instance.
"""
tags = TaggableManager(
- through='extras.TaggedItem'
+ through='extras.TaggedItem',
+ ordering=('weight', 'name'),
)
class Meta:
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index a0c97ac3f..43dcaeed2 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -17,6 +17,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
from netbox.plugins import PluginConfig
from netbox.registry import registry
+import storages.utils # type: ignore
from utilities.release import load_release_data
from utilities.string import trailing_slash
@@ -52,14 +53,18 @@ except ModuleNotFoundError as e:
)
raise
-# Check for missing required configuration parameters
-for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'):
+# Check for missing/conflicting required configuration parameters
+for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
if not hasattr(configuration, parameter):
raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.")
+if not hasattr(configuration, 'DATABASE') and not hasattr(configuration, 'DATABASES'):
+ raise ImproperlyConfigured("The database configuration must be defined using DATABASE or DATABASES.")
+elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'):
+ raise ImproperlyConfigured("DATABASE and DATABASES may not be set together. The use of DATABASES is encouraged.")
# Set static config parameters
ADMINS = getattr(configuration, 'ADMINS', [])
-ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True)
+ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False)
ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required
AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [
{
@@ -83,7 +88,9 @@ CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
-DATABASE = getattr(configuration, 'DATABASE') # Required
+DATABASE = getattr(configuration, 'DATABASE', None) # Legacy DB definition
+DATABASE_ROUTERS = getattr(configuration, 'DATABASE_ROUTERS', [])
+DATABASES = getattr(configuration, 'DATABASES', {'default': DATABASE})
DEBUG = getattr(configuration, 'DEBUG', False)
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
@@ -128,6 +135,7 @@ LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
+LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
@@ -177,7 +185,8 @@ SESSION_COOKIE_PATH = CSRF_COOKIE_PATH
SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False)
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
-STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
+STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None)
+STORAGES = getattr(configuration, 'STORAGES', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
@@ -217,78 +226,79 @@ for path in PROXY_ROUTERS:
# Database
#
-# Set the database engine
-if 'ENGINE' not in DATABASE:
- if METRICS_ENABLED:
- DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'})
- else:
- DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
+# Verify that a default database has been configured
+if 'default' not in DATABASES:
+ raise ImproperlyConfigured("No default database has been configured.")
-# Define the DATABASES setting for Django
-DATABASES = {
- 'default': DATABASE,
-}
+# Set the database engine
+if 'ENGINE' not in DATABASES['default']:
+ DATABASES['default'].update({
+ 'ENGINE': 'django_prometheus.db.backends.postgresql' if METRICS_ENABLED else 'django.db.backends.postgresql'
+ })
#
# Storage backend
#
+if STORAGE_BACKEND is not None:
+ if not STORAGES:
+ raise ImproperlyConfigured(
+ "STORAGE_BACKEND and STORAGES are both set, remove the deprecated STORAGE_BACKEND setting."
+ )
+ else:
+ warnings.warn(
+ "STORAGE_BACKEND is deprecated, use the new STORAGES setting instead."
+ )
+
+if STORAGE_CONFIG is not None:
+ warnings.warn(
+ "STORAGE_CONFIG is deprecated, use the new STORAGES setting instead."
+ )
+
# Default STORAGES for Django
-STORAGES = {
+DEFAULT_STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
+ "scripts": {
+ "BACKEND": "extras.storage.ScriptFileSystemStorage",
+ },
}
+STORAGES = DEFAULT_STORAGES | STORAGES
+# TODO: This code is deprecated and needs to be removed in the future
if STORAGE_BACKEND is not None:
STORAGES['default']['BACKEND'] = STORAGE_BACKEND
- # django-storages
- if STORAGE_BACKEND.startswith('storages.'):
- try:
- import storages.utils # type: ignore
- except ModuleNotFoundError as e:
- if getattr(e, 'name') == 'storages':
- raise ImproperlyConfigured(
- f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be "
- f"installed by running 'pip install django-storages'."
- )
- raise e
+# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
+if STORAGE_CONFIG is not None:
+ def _setting(name, default=None):
+ if name in STORAGE_CONFIG:
+ return STORAGE_CONFIG[name]
+ return globals().get(name, default)
+ storages.utils.setting = _setting
- # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
- def _setting(name, default=None):
- if name in STORAGE_CONFIG:
- return STORAGE_CONFIG[name]
- return globals().get(name, default)
- storages.utils.setting = _setting
-
- # django-storage-swift
- elif STORAGE_BACKEND == 'swift.storage.SwiftStorage':
- try:
- import swift.utils # noqa: F401
- except ModuleNotFoundError as e:
- if getattr(e, 'name') == 'swift':
- raise ImproperlyConfigured(
- f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
- "It can be installed by running 'pip install django-storage-swift'."
- )
- raise e
-
- # Load all SWIFT_* settings from the user configuration
- for param, value in STORAGE_CONFIG.items():
- if param.startswith('SWIFT_'):
- globals()[param] = value
-
-if STORAGE_CONFIG and STORAGE_BACKEND is None:
- warnings.warn(
- "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be "
- "ignored."
- )
+# django-storage-swift
+if STORAGE_BACKEND == 'swift.storage.SwiftStorage':
+ try:
+ import swift.utils # noqa: F401
+ except ModuleNotFoundError as e:
+ if getattr(e, 'name') == 'swift':
+ raise ImproperlyConfigured(
+ f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. "
+ "It can be installed by running 'pip install django-storage-swift'."
+ )
+ raise e
+ # Load all SWIFT_* settings from the user configuration
+ for param, value in STORAGE_CONFIG.items():
+ if param.startswith('SWIFT_'):
+ globals()[param] = value
+# TODO: End of deprecated code
#
# Redis
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html
index 97dcc20f0..02e02a1ed 100644
--- a/netbox/templates/dcim/location.html
+++ b/netbox/templates/dcim/location.html
@@ -62,6 +62,7 @@
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
{% trans "Group" %} | -{{ object.group|linkify|placeholder }} | +{% trans "Groups" %} | +
+ {% if object.groups.all|length > 0 %}
+
|
---|---|---|---|
{% trans "Name" %} | diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index bf6928c15..25b1da440 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -32,6 +32,7 @@