mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 02:48:38 -06:00
Merge branch 'feature' into 16224-graphql-pagination
This commit is contained in:
commit
81a0cf19a9
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
@ -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'`
|
||||
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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`
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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 |
|
||||
|
@ -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).
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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')
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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():
|
||||
|
@ -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',)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
16
netbox/extras/migrations/0125_exporttemplate_file_name.py
Normal file
16
netbox/extras/migrations/0125_exporttemplate_file_name.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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 <code>text/plain; charset=utf-8</code>')
|
||||
)
|
||||
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
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
|
14
netbox/extras/storage.py
Normal file
14
netbox/extras/storage.py
Normal file
@ -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
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
19
netbox/extras/tests/test_utils.py
Normal file
19
netbox/extras/tests/test_utils.py
Normal file
@ -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)
|
@ -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 = (
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
comments = models.TextField(
|
||||
verbose_name=_('comments'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -62,6 +62,7 @@
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -41,6 +41,7 @@
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -41,6 +41,7 @@
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -23,6 +23,10 @@
|
||||
<th scope="row">{% trans "MIME Type" %}</th>
|
||||
<td>{{ object.mime_type|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Name" %}</th>
|
||||
<td>{{ object.file_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "File Extension" %}</th>
|
||||
<td>{{ object.file_extension|placeholder }}</td>
|
||||
|
@ -28,6 +28,10 @@
|
||||
<span class="color-label" style="background-color: #{{ object.color }}"> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tagged Items" %}</th>
|
||||
<td>
|
||||
|
@ -34,48 +34,55 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="card card-md">
|
||||
<div class="card-body">
|
||||
<h2 class="text-center mb-4">{% trans "Log In" %}</h2>
|
||||
{% if not login_form_hidden %}
|
||||
<div class="card-body">
|
||||
<h2 class="text-center mb-4">{% trans "Log In" %}</h2>
|
||||
|
||||
{# Login form #}
|
||||
<form action="{% url 'login' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{# Login form #}
|
||||
<form action="{% url 'login' %}" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{# Set post-login URL #}
|
||||
{% if 'next' in request.GET %}
|
||||
<input type="hidden" name="next" value="{{ request.GET.next }}" />
|
||||
{% elif 'next' in request.POST %}
|
||||
<input type="hidden" name="next" value="{{ request.POST.next }}" />
|
||||
{% endif %}
|
||||
{# Set post-login URL #}
|
||||
{% if 'next' in request.GET %}
|
||||
<input type="hidden" name="next" value="{{ request.GET.next }}" />
|
||||
{% elif 'next' in request.POST %}
|
||||
<input type="hidden" name="next" value="{{ request.POST.next }}" />
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="id_username" class="form-label">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-group mb-3">
|
||||
<label for="id_username" class="form-label">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password" class="form-label">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_password" class="form-label">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
{% trans "Sign In" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
{% trans "Sign In" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# SSO login #}
|
||||
{% if auth_backends %}
|
||||
<div class="hr-text">{% trans "Or" context "Denotes an alternative option" %}</div>
|
||||
{% if not login_form_hidden %}
|
||||
<div class="hr-text">{% trans "Or" context "Denotes an alternative option" %}</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
{% if login_form_hidden %}
|
||||
<h2 class="text-center mb-4">{% trans "Log In" %}</h2>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% for backend in auth_backends %}
|
||||
<div class="col">
|
||||
|
@ -18,8 +18,18 @@
|
||||
<h2 class="card-header">{% trans "Contact" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Group" %}</th>
|
||||
<td>{{ object.group|linkify|placeholder }}</td>
|
||||
<th scope="row">{% trans "Groups" %}</th>
|
||||
<td>
|
||||
{% if object.groups.all|length > 0 %}
|
||||
<ol class="list-unstyled mb-0">
|
||||
{% for group in object.groups.all %}
|
||||
<li>{{ group|linkify|placeholder }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
|
@ -32,6 +32,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -40,6 +40,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -40,6 +40,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
|
@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer
|
||||
from tenancy.choices import ContactPriorityChoices
|
||||
from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole
|
||||
@ -26,7 +26,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer):
|
||||
model = ContactGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'contact_count', '_depth',
|
||||
'created', 'last_updated', 'contact_count', 'comments', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth')
|
||||
|
||||
@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer):
|
||||
|
||||
|
||||
class ContactSerializer(NetBoxModelSerializer):
|
||||
group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
groups = SerializedPKRelatedField(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
serializer=ContactGroupSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link',
|
||||
'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
@ -19,7 +19,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
|
||||
model = TenantGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'tenant_count', '_depth',
|
||||
'created', 'last_updated', 'tenant_count', 'comments', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth')
|
||||
|
||||
|
@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = ContactGroup.objects.add_related_count(
|
||||
ContactGroup.objects.all(),
|
||||
Contact,
|
||||
'group',
|
||||
'groups',
|
||||
'contact_count',
|
||||
cumulative=True
|
||||
)
|
||||
|
@ -2,7 +2,7 @@ import django_filters
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet
|
||||
from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter
|
||||
from .models import *
|
||||
|
||||
@ -22,7 +22,7 @@ __all__ = (
|
||||
# Contacts
|
||||
#
|
||||
|
||||
class ContactGroupFilterSet(OrganizationalModelFilterSet):
|
||||
class ContactGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
label=_('Parent contact group (ID)'),
|
||||
@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Contact group (slug)'),
|
||||
)
|
||||
contact_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='contact',
|
||||
queryset=Contact.objects.all(),
|
||||
label=_('Contact (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ContactGroup
|
||||
@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
|
||||
class ContactFilterSet(NetBoxModelFilterSet):
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='group',
|
||||
field_name='groups',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group (ID)'),
|
||||
)
|
||||
group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='group',
|
||||
lookup_expr='in',
|
||||
field_name='groups',
|
||||
to_field_name='slug',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group (slug)'),
|
||||
)
|
||||
|
||||
@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
field_name='contact__groups',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group (ID)'),
|
||||
)
|
||||
group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contact__group',
|
||||
field_name='contact__groups',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Contact group (slug)'),
|
||||
@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
|
||||
)
|
||||
contact_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
field_name='contacts__contact__group',
|
||||
field_name='contacts__contact__groups',
|
||||
lookup_expr='in',
|
||||
label=_('Contact group'),
|
||||
)
|
||||
@ -163,7 +168,7 @@ class ContactModelFilterSet(django_filters.FilterSet):
|
||||
# Tenancy
|
||||
#
|
||||
|
||||
class TenantGroupFilterSet(OrganizationalModelFilterSet):
|
||||
class TenantGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
label=_('Parent tenant group (ID)'),
|
||||
|
@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.choices import ContactPriorityChoices
|
||||
from tenancy.models import *
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
|
||||
__all__ = (
|
||||
@ -33,9 +33,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = TenantGroup
|
||||
nullable_fields = ('parent', 'description')
|
||||
nullable_fields = ('parent', 'description', 'comments')
|
||||
|
||||
|
||||
class TenantBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@ -67,12 +68,13 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = ContactGroup
|
||||
fieldsets = (
|
||||
FieldSet('parent', 'description'),
|
||||
)
|
||||
nullable_fields = ('parent', 'description')
|
||||
nullable_fields = ('parent', 'description', 'comments')
|
||||
|
||||
|
||||
class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@ -90,8 +92,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class ContactBulkEditForm(NetBoxModelBulkEditForm):
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
add_groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Add groups'),
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
remove_groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Remove groups'),
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
@ -127,9 +134,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Contact
|
||||
fieldsets = (
|
||||
FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'),
|
||||
FieldSet('title', 'phone', 'email', 'address', 'link', 'description'),
|
||||
FieldSet('add_groups', 'remove_groups', name=_('Groups')),
|
||||
)
|
||||
|
||||
nullable_fields = (
|
||||
'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments'
|
||||
)
|
||||
nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments')
|
||||
|
||||
|
||||
class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField
|
||||
from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'ContactAssignmentImportForm',
|
||||
@ -31,7 +31,7 @@ class TenantGroupImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = TenantGroup
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags')
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
|
||||
|
||||
|
||||
class TenantImportForm(NetBoxModelImportForm):
|
||||
@ -65,7 +65,7 @@ class ContactGroupImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ContactGroup
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags')
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
|
||||
|
||||
|
||||
class ContactRoleImportForm(NetBoxModelImportForm):
|
||||
@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm):
|
||||
|
||||
|
||||
class ContactImportForm(NetBoxModelImportForm):
|
||||
group = CSVModelChoiceField(
|
||||
label=_('Group'),
|
||||
groups = CSVModelMultipleChoiceField(
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned group')
|
||||
help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags')
|
||||
fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags')
|
||||
|
||||
|
||||
class ContactAssignmentImportForm(NetBoxModelImportForm):
|
||||
|
@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm):
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
label=_('Group')
|
||||
label=_('Groups')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
||||
from utilities.forms.rendering import FieldSet, ObjectAttribute
|
||||
|
||||
__all__ = (
|
||||
@ -27,6 +27,7 @@ class TenantGroupForm(NetBoxModelForm):
|
||||
required=False
|
||||
)
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')),
|
||||
@ -35,7 +36,7 @@ class TenantGroupForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = TenantGroup
|
||||
fields = [
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
'parent', 'name', 'slug', 'description', 'tags', 'comments'
|
||||
]
|
||||
|
||||
|
||||
@ -70,6 +71,7 @@ class ContactGroupForm(NetBoxModelForm):
|
||||
required=False
|
||||
)
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')),
|
||||
@ -77,7 +79,7 @@ class ContactGroupForm(NetBoxModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ContactGroup
|
||||
fields = ('parent', 'name', 'slug', 'description', 'tags')
|
||||
fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments')
|
||||
|
||||
|
||||
class ContactRoleForm(NetBoxModelForm):
|
||||
@ -93,8 +95,8 @@ class ContactRoleForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class ContactForm(NetBoxModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
label=_('Group'),
|
||||
groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Groups'),
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
@ -102,7 +104,7 @@ class ContactForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
|
||||
'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags',
|
||||
name=_('Contact')
|
||||
),
|
||||
)
|
||||
@ -110,7 +112,7 @@ class ContactForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Contact
|
||||
fields = (
|
||||
'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags',
|
||||
'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags',
|
||||
)
|
||||
widgets = {
|
||||
'address': forms.Textarea(attrs={'rows': 3}),
|
||||
@ -123,7 +125,7 @@ class ContactAssignmentForm(NetBoxModelForm):
|
||||
queryset=ContactGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'contacts': '$contact'
|
||||
'contact': '$contact'
|
||||
}
|
||||
)
|
||||
contact = DynamicModelChoiceField(
|
||||
|
@ -112,7 +112,7 @@ class TenantGroupType(OrganizationalObjectType):
|
||||
pagination=True
|
||||
)
|
||||
class ContactType(ContactAssignmentsMixin, NetBoxObjectType):
|
||||
group: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None
|
||||
groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
68
netbox/tenancy/migrations/0018_contact_groups.py
Normal file
68
netbox/tenancy/migrations/0018_contact_groups.py
Normal file
@ -0,0 +1,68 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_contact_groups(apps, schema_editor):
|
||||
Contacts = apps.get_model('tenancy', 'Contact')
|
||||
|
||||
qs = Contacts.objects.filter(group__isnull=False)
|
||||
for contact in qs:
|
||||
contact.groups.add(contact.group)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0017_natural_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactGroupMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'contact group membership',
|
||||
'verbose_name_plural': 'contact group memberships',
|
||||
},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='contact',
|
||||
name='tenancy_contact_unique_group_name',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contactgroupmembership',
|
||||
name='contact',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contactgroupmembership',
|
||||
name='group',
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup'
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='contact',
|
||||
name='groups',
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name='contacts',
|
||||
related_query_name='contact',
|
||||
through='tenancy.ContactGroupMembership',
|
||||
to='tenancy.contactgroup',
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='contactgroupmembership',
|
||||
constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'),
|
||||
),
|
||||
migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name='contact',
|
||||
name='group',
|
||||
),
|
||||
]
|
@ -0,0 +1,21 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0018_contact_groups'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='contactgroup',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenantgroup',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
@ -13,6 +13,7 @@ __all__ = (
|
||||
'ContactAssignment',
|
||||
'Contact',
|
||||
'ContactGroup',
|
||||
'ContactGroupMembership',
|
||||
'ContactRole',
|
||||
)
|
||||
|
||||
@ -47,12 +48,12 @@ class Contact(PrimaryModel):
|
||||
"""
|
||||
Contact information for a particular object(s) in NetBox.
|
||||
"""
|
||||
group = models.ForeignKey(
|
||||
groups = models.ManyToManyField(
|
||||
to='tenancy.ContactGroup',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='contacts',
|
||||
blank=True,
|
||||
null=True
|
||||
through='tenancy.ContactGroupMembership',
|
||||
related_query_name='contact',
|
||||
blank=True
|
||||
)
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
@ -84,17 +85,11 @@ class Contact(PrimaryModel):
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'group', 'name', 'title', 'phone', 'email', 'address', 'link',
|
||||
'groups', 'name', 'title', 'phone', 'email', 'address', 'link',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('group', 'name'),
|
||||
name='%(app_label)s_%(class)s_unique_group_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('contact')
|
||||
verbose_name_plural = _('contacts')
|
||||
|
||||
@ -102,6 +97,18 @@ class Contact(PrimaryModel):
|
||||
return self.name
|
||||
|
||||
|
||||
class ContactGroupMembership(models.Model):
|
||||
group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE)
|
||||
contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name')
|
||||
]
|
||||
verbose_name = _('contact group membership')
|
||||
verbose_name_plural = _('contact group memberships')
|
||||
|
||||
|
||||
class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
object_type = models.ForeignKey(
|
||||
to='contenttypes.ContentType',
|
||||
|
@ -25,6 +25,7 @@ class ContactGroupIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
@ -59,5 +60,6 @@ class TenantGroupIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
@ -27,11 +27,15 @@ class ContactGroupTable(NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='tenancy:contactgroup_list'
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ContactGroup
|
||||
fields = (
|
||||
'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'contact_count', 'description')
|
||||
|
||||
@ -56,9 +60,9 @@ class ContactTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
group = tables.Column(
|
||||
verbose_name=_('Group'),
|
||||
linkify=True
|
||||
groups = columns.ManyToManyColumn(
|
||||
verbose_name=_('Groups'),
|
||||
linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')})
|
||||
)
|
||||
phone = tables.Column(
|
||||
verbose_name=_('Phone'),
|
||||
@ -79,10 +83,10 @@ class ContactTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Contact
|
||||
fields = (
|
||||
'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments',
|
||||
'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments',
|
||||
'assignment_count', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')
|
||||
default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email')
|
||||
|
||||
|
||||
class ContactAssignmentTable(NetBoxTable):
|
||||
|
@ -24,11 +24,15 @@ class TenantGroupTable(NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='tenancy:tenantgroup_list'
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = TenantGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'tenant_count', 'description')
|
||||
|
||||
|
@ -21,6 +21,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
'comments': 'New Comment',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -28,12 +29,17 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
parent_tenant_groups = (
|
||||
TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
|
||||
TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
|
||||
TenantGroup.objects.create(
|
||||
name='Parent Tenant Group 2', slug='parent-tenant-group-2', comments='Parent Group 2 comment',
|
||||
),
|
||||
)
|
||||
|
||||
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0])
|
||||
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0])
|
||||
TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0])
|
||||
TenantGroup.objects.create(
|
||||
name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0],
|
||||
comments='Tenant Group 3 comment'
|
||||
)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
@ -50,6 +56,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Tenant Group 6',
|
||||
'slug': 'tenant-group-6',
|
||||
'parent': parent_tenant_groups[1].pk,
|
||||
'comments': 'Tenant Group 6 comment',
|
||||
},
|
||||
]
|
||||
|
||||
@ -107,13 +114,18 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
parent_contact_groups = (
|
||||
ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'),
|
||||
ContactGroup.objects.create(
|
||||
name='Parent Contact Group 1', slug='parent-contact-group-1', comments='Parent 1 comment'
|
||||
),
|
||||
ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'),
|
||||
)
|
||||
|
||||
ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0])
|
||||
ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0])
|
||||
ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0])
|
||||
ContactGroup.objects.create(
|
||||
name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0],
|
||||
comments='Child Group 3 comment',
|
||||
)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
@ -125,11 +137,13 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Contact Group 5',
|
||||
'slug': 'contact-group-5',
|
||||
'parent': parent_contact_groups[1].pk,
|
||||
'comments': '',
|
||||
},
|
||||
{
|
||||
'name': 'Contact Group 6',
|
||||
'slug': 'contact-group-6',
|
||||
'parent': parent_contact_groups[1].pk,
|
||||
'comments': 'Child Group 6 comment',
|
||||
},
|
||||
]
|
||||
|
||||
@ -170,7 +184,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Contact
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'group': None,
|
||||
'groups': [],
|
||||
'comments': 'New comments',
|
||||
}
|
||||
|
||||
@ -183,20 +197,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0]),
|
||||
Contact(name='Contact 2', group=contact_groups[0]),
|
||||
Contact(name='Contact 3', group=contact_groups[0]),
|
||||
Contact(name='Contact 1'),
|
||||
Contact(name='Contact 2'),
|
||||
Contact(name='Contact 3'),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
contacts[0].groups.add(contact_groups[0])
|
||||
contacts[1].groups.add(contact_groups[0])
|
||||
contacts[2].groups.add(contact_groups[0])
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Contact 4',
|
||||
'group': contact_groups[1].pk,
|
||||
'groups': [contact_groups[1].pk],
|
||||
},
|
||||
{
|
||||
'name': 'Contact 5',
|
||||
'group': contact_groups[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Contact 6',
|
||||
|
@ -16,7 +16,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
parent_tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Parent group 2 comment'),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
|
||||
)
|
||||
for tenant_group in parent_tenant_groups:
|
||||
@ -27,7 +27,8 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Tenant Group 1A',
|
||||
slug='tenant-group-1a',
|
||||
parent=parent_tenant_groups[0],
|
||||
description='foobar1'
|
||||
description='foobar1',
|
||||
comments='Tenant Group 1A comment',
|
||||
),
|
||||
TenantGroup(
|
||||
name='Tenant Group 2A',
|
||||
@ -48,7 +49,10 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
child_tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]),
|
||||
TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]),
|
||||
TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]),
|
||||
TenantGroup(
|
||||
name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2],
|
||||
comments='Tenant Group 3A1 comment',
|
||||
),
|
||||
)
|
||||
for tenant_group in child_tenant_groups:
|
||||
tenant_group.save()
|
||||
@ -57,6 +61,13 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_q_comments(self):
|
||||
params = {'q': 'parent'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
params = {'q': 'comment'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Tenant Group 1', 'Tenant Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@ -139,7 +150,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
parent_contact_groups = (
|
||||
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
|
||||
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
|
||||
ContactGroup(name='Contact Group 2', slug='contact-group-2', comments='Parent group 2'),
|
||||
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
|
||||
)
|
||||
for contact_group in parent_contact_groups:
|
||||
@ -162,14 +173,18 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
name='Contact Group 3A',
|
||||
slug='contact-group-3a',
|
||||
parent=parent_contact_groups[2],
|
||||
description='foobar3'
|
||||
description='foobar3',
|
||||
comments='Contact Group 3A comment, not a parent',
|
||||
),
|
||||
)
|
||||
for contact_group in contact_groups:
|
||||
contact_group.save()
|
||||
|
||||
child_contact_groups = (
|
||||
ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]),
|
||||
ContactGroup(
|
||||
name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0],
|
||||
comments='Contact Group 1A1 comment',
|
||||
),
|
||||
ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]),
|
||||
ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]),
|
||||
)
|
||||
@ -180,6 +195,13 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_q_comments(self):
|
||||
params = {'q': 'parent'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
params = {'q': '1A1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Contact Group 1', 'Contact Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@ -241,6 +263,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Contact.objects.all()
|
||||
filterset = ContactFilterSet
|
||||
ignore_fields = ('groups',)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -254,11 +277,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
contactgroup.save()
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0], description='foobar1'),
|
||||
Contact(name='Contact 2', group=contact_groups[1], description='foobar2'),
|
||||
Contact(name='Contact 3', group=contact_groups[2], description='foobar3'),
|
||||
Contact(name='Contact 1', description='foobar1'),
|
||||
Contact(name='Contact 2', description='foobar2'),
|
||||
Contact(name='Contact 3', description='foobar3'),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
contacts[0].groups.add(contact_groups[0])
|
||||
contacts[1].groups.add(contact_groups[1])
|
||||
contacts[2].groups.add(contact_groups[2])
|
||||
|
||||
def test_q(self):
|
||||
params = {'q': 'foobar1'}
|
||||
@ -311,11 +337,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
ContactRole.objects.bulk_create(contact_roles)
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0]),
|
||||
Contact(name='Contact 2', group=contact_groups[1]),
|
||||
Contact(name='Contact 3', group=contact_groups[2]),
|
||||
Contact(name='Contact 1'),
|
||||
Contact(name='Contact 2'),
|
||||
Contact(name='Contact 3'),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
contacts[0].groups.add(contact_groups[0])
|
||||
contacts[1].groups.add(contact_groups[1])
|
||||
contacts[2].groups.add(contact_groups[2])
|
||||
|
||||
assignments = (
|
||||
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
|
||||
|
@ -15,7 +15,7 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
|
||||
tenant_groups = (
|
||||
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
|
||||
TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Tenant Group 2 comment'),
|
||||
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
|
||||
)
|
||||
for tenanantgroup in tenant_groups:
|
||||
@ -28,24 +28,26 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'slug': 'tenant-group-x',
|
||||
'description': 'A new tenant group',
|
||||
'tags': [t.pk for t in tags],
|
||||
'comments': 'Tenant Group X comment',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"Tenant Group 4,tenant-group-4,Fourth tenant group",
|
||||
"Tenant Group 5,tenant-group-5,Fifth tenant group",
|
||||
"Tenant Group 6,tenant-group-6,Sixth tenant group",
|
||||
"name,slug,description,comments",
|
||||
"Tenant Group 4,tenant-group-4,Fourth tenant group,",
|
||||
"Tenant Group 5,tenant-group-5,Fifth tenant group,",
|
||||
"Tenant Group 6,tenant-group-6,Sixth tenant group,Sixth tenant group comment",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7",
|
||||
f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8",
|
||||
f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9",
|
||||
"id,name,description,comments",
|
||||
f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7,Group 7 comment",
|
||||
f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8,",
|
||||
f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9,",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
'comments': 'New comment',
|
||||
}
|
||||
|
||||
|
||||
@ -106,7 +108,7 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
contact_groups = (
|
||||
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
|
||||
ContactGroup(name='Contact Group 1', slug='contact-group-1', comments='Comment 1'),
|
||||
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
|
||||
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
|
||||
)
|
||||
@ -120,24 +122,26 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'slug': 'contact-group-x',
|
||||
'description': 'A new contact group',
|
||||
'tags': [t.pk for t in tags],
|
||||
'comments': 'Form data comment',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"Contact Group 4,contact-group-4,Fourth contact group",
|
||||
"Contact Group 5,contact-group-5,Fifth contact group",
|
||||
"Contact Group 6,contact-group-6,Sixth contact group",
|
||||
"name,slug,description,comments",
|
||||
"Contact Group 4,contact-group-4,Fourth contact group,",
|
||||
"Contact Group 5,contact-group-5,Fifth contact group,Fifth comment",
|
||||
"Contact Group 6,contact-group-6,Sixth contact group,",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7",
|
||||
f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8",
|
||||
f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9",
|
||||
"id,name,description,comments",
|
||||
f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7,",
|
||||
f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8,Group 8 comment",
|
||||
f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9,",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
'comments': 'Bulk update comment',
|
||||
}
|
||||
|
||||
|
||||
@ -196,37 +200,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
contactgroup.save()
|
||||
|
||||
contacts = (
|
||||
Contact(name='Contact 1', group=contact_groups[0]),
|
||||
Contact(name='Contact 2', group=contact_groups[0]),
|
||||
Contact(name='Contact 3', group=contact_groups[0]),
|
||||
Contact(name='Contact 1'),
|
||||
Contact(name='Contact 2'),
|
||||
Contact(name='Contact 3'),
|
||||
)
|
||||
Contact.objects.bulk_create(contacts)
|
||||
contacts[0].groups.add(contact_groups[0])
|
||||
contacts[1].groups.add(contact_groups[1])
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Contact X',
|
||||
'group': contact_groups[1].pk,
|
||||
'groups': [contact_groups[1].pk],
|
||||
'comments': 'Some comments',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"group,name",
|
||||
"Contact Group 1,Contact 4",
|
||||
"Contact Group 1,Contact 5",
|
||||
"Contact Group 1,Contact 6",
|
||||
"name",
|
||||
"groups",
|
||||
"Contact 4",
|
||||
"Contact 5",
|
||||
"Contact 6",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,comments",
|
||||
f"{contacts[0].pk},Contact Group 7,New comments 7",
|
||||
f"{contacts[1].pk},Contact Group 8,New comments 8",
|
||||
f"{contacts[2].pk},Contact Group 9,New comments 9",
|
||||
"id,name,groups,comments",
|
||||
f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7',
|
||||
f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8',
|
||||
f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'group': contact_groups[1].pk,
|
||||
'description': "New description",
|
||||
}
|
||||
|
||||
|
||||
|
@ -170,7 +170,7 @@ class ContactGroupListView(generic.ObjectListView):
|
||||
queryset = ContactGroup.objects.add_related_count(
|
||||
ContactGroup.objects.all(),
|
||||
Contact,
|
||||
'group',
|
||||
'groups',
|
||||
'contact_count',
|
||||
cumulative=True
|
||||
)
|
||||
@ -214,7 +214,7 @@ class ContactGroupBulkEditView(generic.BulkEditView):
|
||||
queryset = ContactGroup.objects.add_related_count(
|
||||
ContactGroup.objects.all(),
|
||||
Contact,
|
||||
'group',
|
||||
'groups',
|
||||
'contact_count',
|
||||
cumulative=True
|
||||
)
|
||||
@ -228,7 +228,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ContactGroup.objects.add_related_count(
|
||||
ContactGroup.objects.all(),
|
||||
Contact,
|
||||
'group',
|
||||
'groups',
|
||||
'contact_count',
|
||||
cumulative=True
|
||||
)
|
||||
@ -337,6 +337,15 @@ class ContactBulkEditView(generic.BulkEditView):
|
||||
table = tables.ContactTable
|
||||
form = forms.ContactBulkEditForm
|
||||
|
||||
def post_save_operations(self, form, obj):
|
||||
super().post_save_operations(form, obj)
|
||||
|
||||
# Add/remove groups
|
||||
if form.cleaned_data.get('add_groups', None):
|
||||
obj.groups.add(*form.cleaned_data['add_groups'])
|
||||
if form.cleaned_data.get('remove_groups', None):
|
||||
obj.groups.remove(*form.cleaned_data['remove_groups'])
|
||||
|
||||
|
||||
@register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
|
||||
class ContactBulkDeleteView(generic.BulkDeleteView):
|
||||
|
@ -144,8 +144,8 @@ class BaseFilterSetTests:
|
||||
# Check that the filter class is correct
|
||||
filter = filters[filter_name]
|
||||
if filter_class is not None:
|
||||
self.assertIs(
|
||||
type(filter),
|
||||
self.assertIsInstance(
|
||||
filter,
|
||||
filter_class,
|
||||
f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer):
|
||||
model = WirelessLANGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'wirelesslan_count', '_depth',
|
||||
'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth')
|
||||
|
||||
|
@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
|
||||
from dcim.base_filtersets import ScopedFilterSet
|
||||
from dcim.models import Interface
|
||||
from ipam.models import VLAN
|
||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from utilities.filters import TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
@ -18,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class WirelessLANGroupFilterSet(OrganizationalModelFilterSet):
|
||||
class WirelessLANGroupFilterSet(NestedGroupModelFilterSet):
|
||||
parent_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=WirelessLANGroup.objects.all()
|
||||
)
|
||||
|
@ -32,12 +32,13 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = WirelessLANGroup
|
||||
fieldsets = (
|
||||
FieldSet('parent', 'description'),
|
||||
)
|
||||
nullable_fields = ('parent', 'description')
|
||||
nullable_fields = ('parent', 'description', 'comments')
|
||||
|
||||
|
||||
class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
|
||||
|
@ -30,7 +30,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = WirelessLANGroup
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags')
|
||||
fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments')
|
||||
|
||||
|
||||
class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
|
@ -24,6 +24,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
|
||||
required=False
|
||||
)
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')),
|
||||
@ -32,7 +33,7 @@ class WirelessLANGroupForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = WirelessLANGroup
|
||||
fields = [
|
||||
'parent', 'name', 'slug', 'description', 'tags',
|
||||
'parent', 'name', 'slug', 'description', 'tags', 'comments',
|
||||
]
|
||||
|
||||
|
||||
|
16
netbox/wireless/migrations/0014_wirelesslangroup_comments.py
Normal file
16
netbox/wireless/migrations/0014_wirelesslangroup_comments.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('wireless', '0013_natural_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wirelesslangroup',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
@ -21,6 +21,7 @@ class WirelessLANGroupIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
@ -24,10 +24,12 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'name': 'Wireless LAN Group 4',
|
||||
'slug': 'wireless-lan-group-4',
|
||||
'comments': '',
|
||||
},
|
||||
{
|
||||
'name': 'Wireless LAN Group 5',
|
||||
'slug': 'wireless-lan-group-5',
|
||||
'comments': 'LAN Group 5 comment',
|
||||
},
|
||||
{
|
||||
'name': 'Wireless LAN Group 6',
|
||||
@ -36,6 +38,7 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
'comments': 'New comment',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
@ -21,7 +21,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
parent_groups = (
|
||||
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'),
|
||||
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'),
|
||||
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'),
|
||||
WirelessLANGroup(
|
||||
name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C',
|
||||
comments='Parent Group 3 comment',
|
||||
),
|
||||
)
|
||||
for group in parent_groups:
|
||||
group.save()
|
||||
@ -38,10 +41,15 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
slug='wireless-lan-group-1b',
|
||||
parent=parent_groups[0],
|
||||
description='foobar2',
|
||||
comments='Child Group 1B comment',
|
||||
),
|
||||
WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]),
|
||||
WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]),
|
||||
WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]),
|
||||
WirelessLANGroup(
|
||||
name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2],
|
||||
comments='Wireless LAN Group 3A comment',
|
||||
|
||||
),
|
||||
WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]),
|
||||
)
|
||||
for group in groups:
|
||||
@ -62,6 +70,13 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'q': 'foobar1'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_q_comments(self):
|
||||
params = {'q': 'parent'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
params = {'q': 'comment'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -16,7 +16,9 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
|
||||
groups = (
|
||||
WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'),
|
||||
WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'),
|
||||
WirelessLANGroup(
|
||||
name='Wireless LAN Group 2', slug='wireless-lan-group-2', comments='LAN Group 2 comment',
|
||||
),
|
||||
WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'),
|
||||
)
|
||||
for group in groups:
|
||||
@ -30,24 +32,26 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'parent': groups[2].pk,
|
||||
'description': 'A new wireless LAN group',
|
||||
'tags': [t.pk for t in tags],
|
||||
'comments': 'LAN Group X comment',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group",
|
||||
"Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group",
|
||||
"Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group",
|
||||
"name,slug,description,comments",
|
||||
"Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group,",
|
||||
"Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group,",
|
||||
"Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group,LAN Group 6 comment",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,name,description",
|
||||
f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7",
|
||||
f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8",
|
||||
f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9",
|
||||
"id,name,description,comments",
|
||||
f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7,Group 7 comment",
|
||||
f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8,",
|
||||
f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9,",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
'comments': 'New Comments',
|
||||
}
|
||||
|
||||
|
||||
|
@ -10,6 +10,7 @@ django-prometheus==2.3.1
|
||||
django-redis==5.4.0
|
||||
django-rich==1.13.0
|
||||
django-rq==3.0
|
||||
django-storages==1.14.4
|
||||
django-taggit==6.1.0
|
||||
django-tables2==2.7.5
|
||||
django-timezone-field==7.1
|
||||
|
Loading…
Reference in New Issue
Block a user