diff --git a/base_requirements.txt b/base_requirements.txt index 75ee4bbfd..7eaa9d928 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<5.2 +Django==5.2.* # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -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 @@ -78,6 +82,10 @@ gunicorn # https://jinja.palletsprojects.com/changes/ Jinja2 +# JSON schema validation +# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst +jsonschema + # Simple markup language for rendering HTML # https://python-markdown.github.io/changelog/ Markdown diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 7cc4d3832..f702c3ffd 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema. By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files. !!! note - These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend). + These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storages). ### Archive the Media Directory diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md index a3e691f63..765c3d50f 100644 --- a/docs/configuration/plugins.md +++ b/docs/configuration/plugins.md @@ -33,3 +33,21 @@ Note that a plugin must be listed in `PLUGINS` for its configuration to take eff --- +## PLUGINS_CATALOG_CONFIG + +Default: Empty + +This parameter controls how individual plugins are displayed in the plugins catalog under Admin > System > Plugins. Adding a plugin to the `hidden` list will omit that plugin from the catalog. Adding a plugin to the `static` list will display the plugin, but not link to the plugin details or upgrade instructions. + +An example configuration is shown below: + +```python +PLUGINS_CATALOG_CONFIG = { + 'hidden': [ + 'plugin1', + ], + 'static': [ + 'plugin2', + ], +} +``` diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index f7e5d71ce..4a18e8a6c 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,30 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 13 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +!!! warning "Legacy Configuration Parameter" + The `DATABASE` configuration parameter is deprecated and will be removed in a future release. Users are advised to adopt the new `DATABASES` (plural) parameter, which allows for the configuration of multiple databases. + +See the [`DATABASES`](#databases) configuration below for usage. + +--- + +## DATABASES + +!!! info "This parameter was introduced in NetBox v4.3." + +NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: + +```python +DATABASES = { + 'default': {...}, + 'external1': {...}, + 'external2': {...}, +} +``` + +NetBox itself requires only that a `default` database is defined. However, certain plugins may require the configuration of additional databases. (Consider also configuring the [`DATABASE_ROUTERS`](./system.md#database_routers) parameter when multiple databases are in use.) + +The following parameters must be defined for each database: * `NAME` - Database name * `USER` - PostgreSQL username @@ -38,14 +61,16 @@ NetBox requires access to a PostgreSQL 13 or later database service to store dat Example: ```python -DATABASE = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age + } } ``` @@ -53,7 +78,7 @@ DATABASE = { NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). !!! warning - Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql. + The `ENGINE` parameter must specify a PostgreSQL-compatible database backend. If not defined, the default engine `django.db.backends.postgresql` will be used. --- diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b97f31432..950d2df34 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -2,7 +2,10 @@ ## ALLOW_TOKEN_RETRIEVAL -Default: True +Default: False + +!!! note + The default value of this parameter changed from true to false in NetBox v4.3.0. If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. @@ -186,6 +189,17 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u --- +## LOGIN_FORM_HIDDEN + +Default: False + +Option to hide the login form when only SSO authentication is in use. + +!!! warning + If the SSO provider is unreachable, login to NetBox will be impossible if this option is enabled. The only recourse is to disable it in the local configuration and restart the NetBox service. + +--- + ## LOGOUT_REDIRECT_URL Default: `'home'` diff --git a/docs/configuration/system.md b/docs/configuration/system.md index af3a6f5e6..11db09370 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -12,6 +12,14 @@ BASE_PATH = 'netbox/' --- +## DATABASE_ROUTERS + +Default: `[]` (empty list) + +An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. + +--- + ## DEFAULT_LANGUAGE Default: `en-us` (US English) @@ -64,7 +72,7 @@ Email is sent from NetBox only for critical events or if configured for [logging ## HTTP_PROXIES -Default: None +Default: Empty A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example: @@ -75,6 +83,8 @@ HTTP_PROXIES = { } ``` +If more flexibility is needed in determining which proxy to use for a given request, consider implementing one or more custom proxy routers via the [`PROXY_ROUTERS`](#proxy_routers) parameter. + --- ## INTERNAL_IPS @@ -160,6 +170,16 @@ The file path to the location where media files (such as image attachments) are --- +## PROXY_ROUTERS + +Default: `["utilities.proxy.DefaultProxyRouter"]` + +A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class. + +The `route()` method on each class must return a dictionary of candidate proxies arranged by protocol (e.g. `http` and/or `https`), or None if no viable proxy can be determined. The default class, `DefaultProxyRouter`, simply returns the content of [`HTTP_PROXIES`](#http_proxies). + +--- + ## REPORTS_ROOT Default: `$INSTALL_ROOT/netbox/reports/` @@ -184,23 +204,46 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend` --- -## STORAGE_BACKEND +## STORAGES -Default: None (local storage) +The backend storage engine for handling uploaded files such as [image attachments](../models/extras/imageattachment.md) and [custom scripts](../customization/custom-scripts.md). NetBox integrates with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) libraries, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used. +By default, the following configuration is used: -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. +```python +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, +} +``` ---- +Within the `STORAGES` dictionary, `"default"` is used for image uploads, "staticfiles" is for static files and `"scripts"` is used for custom scripts. -## STORAGE_CONFIG +If using a remote storage like S3, define the config as `STORAGES[key]["OPTIONS"]` for each storage item as needed. For example: -Default: Empty +```python +STORAGES = { + "scripts": { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "OPTIONS": { + 'access_key': 'access key', + 'secret_key': 'secret key', + } + }, +} +``` -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail. +The specific configuration settings for each storage backend can be found in the [django-storages documentation](https://django-storages.readthedocs.io/en/latest/index.html). -If `STORAGE_BACKEND` is not defined, this setting will be ignored. +!!! note + Any keys defined in the `STORAGES` configuration parameter replace those in the default configuration. It is only necessary to define keys within the `STORAGES` for the specific backend(s) you wish to configure. --- diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 1051b31f6..56dd08a76 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -140,6 +140,8 @@ The Script class provides two convenience methods for reading data from files: These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`). +**Note:** These convenience methods are deprecated and will be removed in NetBox v4.4. These only work if running scripts within the local path, they will not work if using a storage other than ScriptFileSystemStorage. + ## Logging The Script object provides a set of convenient functions for recording messages at different severity levels: diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index 0bf020662..da0e49511 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -76,11 +76,13 @@ Create the following for each model: ## 13. GraphQL API components -Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. +Create the following for each model: -**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["policy"])` or similar. +* GraphQL object type for the model in `graphql/types.py` (subclass the appropriate class from `netbox.graphql.types`) +* Add a GraphQL filter for the model in `graphql/filters.py` +* Extend the query class for the app in `graphql/schema.py` with the individual object and object list fields -Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. +**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["foo"])` or similar. ## 14. Add tests diff --git a/docs/development/extending-models.md b/docs/development/extending-models.md index 16d1c3451..d870a371d 100644 --- a/docs/development/extending-models.md +++ b/docs/development/extending-models.md @@ -6,7 +6,7 @@ Below is a list of tasks to consider when adding a new field to a core model. Add the field to the model, taking care to address any of the following conditions. -* When adding a GenericForeignKey field, also add an index under `Meta` for its two concrete fields. For example: +* When adding a GenericForeignKey field, you may need add an index under `Meta` for its two concrete fields. (This is required only for non-unique GFK relationships, as the unique constraint introduces its own index.) For example: ```python class Meta: diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 0b77bfd4d..129bf2d4b 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -115,7 +115,7 @@ You may also need to set up the yarn packages as shown in the [Web UI Developmen Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes -* `DATABASE`: PostgreSQL database connection parameters +* `DATABASES`: PostgreSQL database connection parameters * `REDIS`: Redis configuration (if different from the defaults) * `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key) * `DEBUG`: Set to `True` diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 8ba302909..536ecea64 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 13 or later required" - NetBox requires PostgreSQL 13 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 14 or later required" + NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -34,7 +34,7 @@ This section entails the installation and configuration of a local PostgreSQL da sudo systemctl enable --now postgresql ``` -Before continuing, verify that you have installed PostgreSQL 13 or later: +Before continuing, verify that you have installed PostgreSQL 14 or later: ```no-highlight psql -V diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 60d60d4f0..33eef6057 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -128,7 +128,7 @@ sudo cp configuration_example.py configuration.py Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` -* `DATABASE` +* `DATABASES` (or `DATABASE`) * `REDIS` * `SECRET_KEY` @@ -146,18 +146,22 @@ If you are not yet sure what the domain name and/or IP address of the NetBox ins ALLOWED_HOSTS = ['*'] ``` -### DATABASE +### DATABASES -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. +This parameter holds the PostgreSQL database configuration details. The default database must be defined; additional databases may be defined as needed e.g. by plugins. + +A username and password must be defined for the default database. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#databases) for more detail on individual parameters. ```python -DATABASE = { - 'NAME': 'netbox', # Database name - 'USER': 'netbox', # PostgreSQL username - 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password - 'HOST': 'localhost', # Database server - 'PORT': '', # Database port (leave blank for default) - 'CONN_MAX_AGE': 300, # Max database connection age (seconds) +DATABASES = { + 'default': { + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + 'CONN_MAX_AGE': 300, # Max database connection age (seconds) + } } ``` @@ -207,7 +211,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storages) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" diff --git a/docs/installation/index.md b/docs/installation/index.md index 33888e274..24e966805 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -21,7 +21,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index e6d05738f..07250e780 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -20,7 +20,7 @@ NetBox requires the following dependencies: | Dependency | Supported Versions | |------------|--------------------| | Python | 3.10, 3.11, 3.12 | -| PostgreSQL | 13+ | +| PostgreSQL | 14+ | | Redis | 4.0+ | ## 3. Install the Latest Release diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index c02045f34..cae046b6c 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ http://netbox/graphql/ \ ---data '{"query": "query {circuit_list(status:\"active\") {cid provider {name}}}"}' +--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}' ``` The response will include the requested data formatted as JSON: @@ -51,19 +51,48 @@ For more detail on constructing GraphQL queries, see the [GraphQL queries docume ## Filtering -The GraphQL API employs the same filtering logic as the UI and REST API. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only sites within the North Carolina region with a status of active: +!!! note "Changed in NetBox v4.3" + The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3. + +Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites: ``` query { - site_list(filters: {region: "us-nc", status: "active"}) { + site_list( + filters: { + status: STATUS_ACTIVE + } + ) { name } } ``` -In addition, filtering can be done on list of related objects as shown in the following query: + +Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo: ``` -{ +query { + site_list( + filters: { + status: STATUS_PLANNED, + OR: { + tenant: { + name: { + exact: "Foo" + } + } + } + } + ) { + name + } +} +``` + +Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device: + +``` +query { device_list { id name @@ -102,6 +131,18 @@ Certain queries can return multiple types of objects, for example cable terminat ``` The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort". +## Pagination + +Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below: + +``` +query { + device_list(pagination: { offset: 0, limit: 20 }) { + id + } +} +``` + ## Authentication NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form: diff --git a/docs/introduction.md b/docs/introduction.md index 75701c119..c8e5ee8ac 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -79,5 +79,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 13+ | +| Database | PostgreSQL 14+ | | Task queuing | Redis/django-rq | diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md index 0e18a2aae..527d93939 100644 --- a/docs/models/core/datasource.md +++ b/docs/models/core/datasource.md @@ -44,6 +44,12 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza | `*.txt` | Ignore any files with a `.txt` extension | | `data???.json` | Ignore e.g. `data123.json` | +### Sync Interval + +!!! info "This field was introduced in NetBox v4.3." + +The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually. + ### Last Synced The date and time at which the source was most recently synchronized successfully. diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 786170f2b..e58373565 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -4,6 +4,10 @@ Devices can be organized by functional roles, which are fully customizable by th ## Fields +### Parent + +The parent role of which this role is a child (optional). + ### Name A unique human-friendly name. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 2d648341b..6aed0fc86 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,5 +1,8 @@ # Inventory Items +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md index 50eb61abd..b77637604 100644 --- a/docs/models/dcim/inventoryitemrole.md +++ b/docs/models/dcim/inventoryitemrole.md @@ -1,5 +1,8 @@ # Inventory Item Roles +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. ## Fields diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md index 02fde5995..7d8ff504d 100644 --- a/docs/models/dcim/inventoryitemtemplate.md +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -1,3 +1,6 @@ # Inventory Item Templates +!!! warning "Deprecation Warning" + Beginning in NetBox v4.3, the use of inventory items has been deprecated. They are planned for removal in a future NetBox release. Users are strongly encouraged to begin using [modules](./module.md) and [module types](./moduletype.md) in place of inventory items. Modules provide enhanced functionality and can be configured with user-defined attributes. + A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. See the [inventory item](./inventoryitem.md) documentation for more detail. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 7077e16c2..88f04466a 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms ### Airflow The direction in which air circulates through the device chassis for cooling. + +### Profile + +The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional. + +### Attributes + +Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure. diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md new file mode 100644 index 000000000..80345c82b --- /dev/null +++ b/docs/models/dcim/moduletypeprofile.md @@ -0,0 +1,40 @@ +# Module Type Profiles + +!!! info "This model was introduced in NetBox v4.3." + +Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor. + +Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes. + +```json +{ + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": ["HD", "SSD", "NVME"], + "default": "HD" + }, + "capacity": { + "type": "integer", + "title": "Capacity (GB)", + "description": "Gross disk size" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "type", "capacity" + ] +} +``` + +The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed. + +## Fields + +### Schema + +This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null). diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index a99f60b23..22a7ec63e 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -29,6 +29,19 @@ An alternative physical label identifying the power outlet. The type of power outlet. +### Status + +The operational status of the power outlet. By default, the following statuses are available: + +* Enabled +* Disabled +* Faulty + +!!! tip "Custom power outlet statuses" + Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Color !!! info "This field was introduced in NetBox v4.2." diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index b5f2d99e7..ecaf539c9 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -40,7 +40,7 @@ The number of the numerically lowest unit in the rack. This value defaults to on ### Outer Dimensions -The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. +The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. ### Mounting Depth diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md deleted file mode 100644 index 4599fed85..000000000 --- a/docs/models/extras/branch.md +++ /dev/null @@ -1,16 +0,0 @@ -# Branches - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes. - -## Fields - -### Name - -The branch's name. - -### User - -The user to which the branch belongs (optional). diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md index b580d6885..6b245e5e9 100644 --- a/docs/models/extras/configtemplate.md +++ b/docs/models/extras/configtemplate.md @@ -12,10 +12,6 @@ See the [configuration rendering documentation](../../features/configuration-ren A unique human-friendly name. -### Weight - -A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight. - ### Data File Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file. @@ -27,3 +23,27 @@ Jinja2 template code, if being defined locally rather than replicated from a dat ### Environment Parameters A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. + +### MIME Type + +!!! info "This field was introduced in NetBox v4.3." + +The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`. + +### File Name + +!!! info "This field was introduced in NetBox v4.3." + +The file name to give to the rendered export file (optional). + +### File Extension + +!!! info "This field was introduced in NetBox v4.3." + +The file extension to append to the file name in the response (optional). + +### As Attachment + +!!! info "This field was introduced in NetBox v4.3." + +If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). \ No newline at end of file diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index d2f9292c6..86e1ae04a 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -20,10 +20,20 @@ Template code may optionally be sourced from a remote [data file](../core/datafi Jinja2 template code for rendering the exported data. +### Environment Parameters + +!!! info "This field was introduced in NetBox v4.3." + +A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. + ### MIME Type 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). + ### File Extension The file extension to append to the file name in the response (optional). diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md deleted file mode 100644 index 0693a32d3..000000000 --- a/docs/models/extras/stagedchange.md +++ /dev/null @@ -1,29 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md). - -Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method. - -## Fields - -!!! warning - Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager. - -### Branch - -The [branch](./branch.md) to which this change belongs. - -### Action - -The type of action this change represents: `create`, `update`, or `delete`. - -### Object - -A generic foreign key referencing the existing object to which this change applies. - -### Data - -JSON representation of the changes being made to the object (not applicable for deletions). diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 39de48261..c4bc91b5a 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -16,6 +16,12 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This The color to use when displaying the tag in the NetBox UI. +### Weight + +A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. + +!!! info "This field was introduced in NetBox v4.3." + ### Object Types The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index 71f0884d9..fd439998a 100644 --- a/docs/models/ipam/iprange.md +++ b/docs/models/ipam/iprange.md @@ -2,6 +2,12 @@ This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md). +Each IP range can be marked as populated, which instructs NetBox to treat the range as though every IP address within it has been created (even though these individual IP addresses don't actually exist in the database). This can be helpful in scenarios where the management of a subset of IP addresses has been deferred to an external system of record, such as a DHCP server. NetBox will prohibit the creation of individual IP addresses within a range that has been marked as populated. + +An IP range can also be marked as utilized. This will cause its utilization to always be reported as 100% when viewing the range or when calculating the utilization of a parent prefix. (If not enabled, a range's utilization is calculated based on the number of IP addresses which have been created within it.) + +Typically, IP ranges marked as populated should also be marked as utilized, although there may be scenarios where this is undesirable (e.g. when reclaiming old IP space). An IP range which has been marked as populated but _not_ marked as utilized will always report a utilization of 0%, as it cannot contain child IP addresses. + ## Fields ### VRF @@ -29,6 +35,12 @@ The IP range's operational status. Note that the status of a range does _not_ ha !!! tip Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. +### Mark Populated + +!!! note "This field was added in NetBox v4.3." + +If enabled, NetBox will treat this IP range as being fully populated when calculating available IP space. It will also prevent the creation of IP addresses which fall within the declared range (and assigned VRF, if any). + ### Mark Utilized If enabled, the IP range will be considered 100% utilized regardless of how many IP addresses are defined within it. This is useful for documenting DHCP ranges, for example. diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index eac630180..f277ab499 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -4,9 +4,11 @@ A contact represents an individual or group that has been associated with an obj ## Fields -### Group +### Groups -The [contact group](./contactgroup.md) to which this contact is assigned (if any). +The [contact groups](./contactgroup.md) to which this contact is assigned (if any). + +!!! info "This field was renamed from `group` to `groups` in NetBox v4.3, and now supports the assignment of a contact to more than one group." ### Name diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 1167c1c17..983095ef8 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -33,6 +33,19 @@ The technology employed in forming and operating the L2VPN. Choices include: !!! note Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. +### Status + +The operational status of the L2VPN. By default, the following statuses are available: + +* Active (default) +* Planned +* Faulty + +!!! tip "Custom L2VPN statuses" + Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +!!! info "This field was introduced in NetBox v4.3." + ### Identifier An optional numeric identifier. This can be used to track a pseudowire ID, for example. diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index d803ce2f4..224802397 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -1,6 +1,6 @@ # Filters & Filter Sets -Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI or REST API. (Note that the GraphQL API uses a separate filter class.) NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. ## FilterSet Classes diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 03cedda16..492b7fc97 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -117,6 +117,8 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.CloningMixin +::: netbox.models.features.ContactsMixin + ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md deleted file mode 100644 index a8fd1d232..000000000 --- a/docs/plugins/development/staged-changes.md +++ /dev/null @@ -1,39 +0,0 @@ -# Staged Changes - -!!! danger "Deprecated Feature" - This feature has been deprecated in NetBox v4.2 and will be removed in a future release. Please consider using the [netbox-branching plugin](https://github.com/netboxlabs/netbox-branching), which provides much more robust functionality. - -NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example. - -To begin staging changes, first create a [branch](../../models/extras/branch.md): - -```python -from extras.models import Branch - -branch1 = Branch.objects.create(name='branch1') -``` - -Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction. - -```python -from extras.models import Branch -from netbox.staging import checkout - -branch1 = Branch.objects.get(name='branch1') -with checkout(branch1): - Site.objects.create(name='New Site', slug='new-site') - # ... -``` - -Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch. - -To apply the changes within a branch, call the branch's `commit()` method: - -```python -from extras.models import Branch - -branch1 = Branch.objects.get(name='branch1') -branch1.commit() -``` - -Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused). diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index e3740de59..43cc0ce82 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -198,6 +198,7 @@ Plugins can inject custom content into certain areas of core NetBox views. This | Method | View | Description | |---------------------|-------------|-----------------------------------------------------| +| `head()` | All | Custom HTML `
` block includes | | `navbar()` | All | Inject content inside the top navigation bar | | `list_buttons()` | List view | Add buttons to the top of the page | | `buttons()` | Object view | Add buttons to the top of the page | diff --git a/mkdocs.yml b/mkdocs.yml index db6798eae..f0bd9af7a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -28,12 +28,7 @@ plugins: - mkdocstrings: handlers: python: - setup_commands: - - import os - - import django - - os.chdir('netbox/') - - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") - - django.setup() + paths: ["netbox"] options: heading_level: 3 members_order: source @@ -150,7 +145,6 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Jobs: 'plugins/development/background-jobs.md' - Dashboard Widgets: 'plugins/development/dashboard-widgets.md' - - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' - Migrating to v4.0: 'plugins/development/migration-v4.md' - Administration: @@ -226,7 +220,6 @@ nav: - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - Extras: - Bookmark: 'models/extras/bookmark.md' - - Branch: 'models/extras/branch.md' - ConfigContext: 'models/extras/configcontext.md' - ConfigTemplate: 'models/extras/configtemplate.md' - CustomField: 'models/extras/customfield.md' @@ -239,7 +232,6 @@ nav: - Notification: 'models/extras/notification.md' - NotificationGroup: 'models/extras/notificationgroup.md' - SavedFilter: 'models/extras/savedfilter.md' - - StagedChange: 'models/extras/stagedchange.md' - Subscription: 'models/extras/subscription.md' - Tag: 'models/extras/tag.md' - Webhook: 'models/extras/webhook.md' diff --git a/netbox/account/migrations/0001_initial.py b/netbox/account/migrations/0001_initial.py index 72c079565..badd459ca 100644 --- a/netbox/account/migrations/0001_initial.py +++ b/netbox/account/migrations/0001_initial.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('users', '0004_netboxgroup_netboxuser'), + ('users', '0002_squashed_0004'), ] operations = [ diff --git a/netbox/account/views.py b/netbox/account/views.py index 05f40df3f..3a2dc6b32 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -89,10 +89,12 @@ class LoginView(View): if request.user.is_authenticated: logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + login_form_hidden = settings.LOGIN_FORM_HIDDEN return render(request, self.template_name, { 'form': form, 'auth_backends': self.get_auth_backends(request), + 'login_form_hidden': login_form_hidden, }) def post(self, request): diff --git a/netbox/circuits/graphql/enums.py b/netbox/circuits/graphql/enums.py new file mode 100644 index 000000000..609e4435b --- /dev/null +++ b/netbox/circuits/graphql/enums.py @@ -0,0 +1,20 @@ +import strawberry + +from circuits.choices import * + +__all__ = ( + 'CircuitStatusEnum', + 'CircuitCommitRateEnum', + 'CircuitTerminationSideEnum', + 'CircuitTerminationPortSpeedEnum', + 'CircuitPriorityEnum', + 'VirtualCircuitTerminationRoleEnum', +) + + +CircuitCommitRateEnum = strawberry.enum(CircuitCommitRateChoices.as_enum()) +CircuitPriorityEnum = strawberry.enum(CircuitPriorityChoices.as_enum()) +CircuitStatusEnum = strawberry.enum(CircuitStatusChoices.as_enum()) +CircuitTerminationSideEnum = strawberry.enum(CircuitTerminationSideChoices.as_enum()) +CircuitTerminationPortSpeedEnum = strawberry.enum(CircuitTerminationPortSpeedChoices.as_enum()) +VirtualCircuitTerminationRoleEnum = strawberry.enum(VirtualCircuitTerminationRoleChoices.as_enum()) diff --git a/netbox/circuits/graphql/filter_mixins.py b/netbox/circuits/graphql/filter_mixins.py new file mode 100644 index 000000000..3ae6fa82e --- /dev/null +++ b/netbox/circuits/graphql/filter_mixins.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django + +from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.enums import ColorEnum + +__all__ = ( + 'BaseCircuitTypeFilterMixin', +) + + +@dataclass +class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py index 7d066f428..48b4252ac 100644 --- a/netbox/circuits/graphql/filters.py +++ b/netbox/circuits/graphql/filters.py @@ -1,7 +1,30 @@ -import strawberry_django +from datetime import date +from typing import Annotated, TYPE_CHECKING -from circuits import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup, DateFilterLookup + +from circuits import models +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import ( + DistanceFilterMixin, + ImageAttachmentFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from .filter_mixins import BaseCircuitTypeFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import InterfaceFilter + from ipam.graphql.filters import ASNFilter + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * __all__ = ( 'CircuitFilter', @@ -19,66 +42,160 @@ __all__ = ( @strawberry_django.filter(models.CircuitTermination, lookups=True) -@autotype_decorator(filtersets.CircuitTerminationFilterSet) -class CircuitTerminationFilter(BaseFilterMixin): - pass +class CircuitTerminationFilter( + BaseObjectTypeFilterMixin, + CustomFieldsFilterMixin, + TagsFilterMixin, + ChangeLogFilterMixin, + CabledObjectModelFilterMixin, +): + circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + term_side: Annotated['CircuitTerminationSideEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() + port_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + upstream_speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + xconnect_id: FilterLookup[str] | None = strawberry_django.filter_field() + pp_info: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Circuit, lookups=True) -@autotype_decorator(filtersets.CircuitFilterSet) -class CircuitFilter(BaseFilterMixin): - pass +class CircuitFilter( + ContactFilterMixin, + ImageAttachmentFilterMixin, + DistanceFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['CircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + install_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + termination_date: DateFilterLookup[date] | None = strawberry_django.filter_field() + commit_rate: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CircuitType, lookups=True) -@autotype_decorator(filtersets.CircuitTypeFilterSet) -class CircuitTypeFilter(BaseFilterMixin): +class CircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.CircuitGroup, lookups=True) -@autotype_decorator(filtersets.CircuitGroupFilterSet) -class CircuitGroupFilter(BaseFilterMixin): +class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.CircuitGroupAssignment, lookups=True) -@autotype_decorator(filtersets.CircuitGroupAssignmentFilterSet) -class CircuitGroupAssignmentFilter(BaseFilterMixin): - pass +class CircuitGroupAssignmentFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + member_id: ID | None = strawberry_django.filter_field() + group: Annotated['CircuitGroupFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + priority: Annotated['CircuitPriorityEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Provider, lookups=True) -@autotype_decorator(filtersets.ProviderFilterSet) -class ProviderFilter(BaseFilterMixin): - pass +class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ProviderAccount, lookups=True) -@autotype_decorator(filtersets.ProviderAccountFilterSet) -class ProviderAccountFilter(BaseFilterMixin): - pass +class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin): + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + account: FilterLookup[str] | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ProviderNetwork, lookups=True) -@autotype_decorator(filtersets.ProviderNetworkFilterSet) -class ProviderNetworkFilter(BaseFilterMixin): - pass +class ProviderNetworkFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_id: ID | None = strawberry_django.filter_field() + service_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualCircuitType, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTypeFilterSet) -class VirtualCircuitTypeFilter(BaseFilterMixin): +class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin): pass @strawberry_django.filter(models.VirtualCircuit, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitFilterSet) -class VirtualCircuitFilter(BaseFilterMixin): - pass +class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + cid: FilterLookup[str] | None = strawberry_django.filter_field() + provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_network_id: ID | None = strawberry_django.filter_field() + provider_account: Annotated['ProviderAccountFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + provider_account_id: ID | None = strawberry_django.filter_field() + type: Annotated['VirtualCircuitTypeFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + status: Annotated['CircuitStatusEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group_assignments: Annotated['CircuitGroupAssignmentFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualCircuitTermination, lookups=True) -@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet) -class VirtualCircuitTerminationFilter(BaseFilterMixin): - pass +class VirtualCircuitTerminationFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_circuit_id: ID | None = strawberry_django.filter_field() + role: Annotated['VirtualCircuitTerminationRoleEnum', strawberry.lazy('circuits.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + interface: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 564b5ed6f..cdd02c891 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -10,11 +10,15 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, ObjectType, O from tenancy.graphql.types import TenantType from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import InterfaceType, LocationType, RegionType, SiteGroupType, SiteType + from ipam.graphql.types import ASNType + __all__ = ( - 'CircuitTerminationType', - 'CircuitType', 'CircuitGroupAssignmentType', 'CircuitGroupType', + 'CircuitTerminationType', + 'CircuitType', 'CircuitTypeType', 'ProviderType', 'ProviderAccountType', @@ -28,7 +32,8 @@ __all__ = ( @strawberry_django.type( models.Provider, fields='__all__', - filters=ProviderFilter + filters=ProviderFilter, + pagination=True ) class ProviderType(NetBoxObjectType, ContactsMixin): @@ -41,7 +46,8 @@ class ProviderType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.ProviderAccount, fields='__all__', - filters=ProviderAccountFilter + filters=ProviderAccountFilter, + pagination=True ) class ProviderAccountType(NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] @@ -52,7 +58,8 @@ class ProviderAccountType(NetBoxObjectType): @strawberry_django.type( models.ProviderNetwork, fields='__all__', - filters=ProviderNetworkFilter + filters=ProviderNetworkFilter, + pagination=True ) class ProviderNetworkType(NetBoxObjectType): provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] @@ -62,8 +69,9 @@ class ProviderNetworkType(NetBoxObjectType): @strawberry_django.type( models.CircuitTermination, - exclude=('termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'), - filters=CircuitTerminationFilter + exclude=['termination_type', 'termination_id', '_location', '_region', '_site', '_site_group', '_provider_network'], + filters=CircuitTerminationFilter, + pagination=True ) class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] @@ -82,7 +90,8 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob @strawberry_django.type( models.CircuitType, fields='__all__', - filters=CircuitTypeFilter + filters=CircuitTypeFilter, + pagination=True ) class CircuitTypeType(OrganizationalObjectType): color: str @@ -93,7 +102,8 @@ class CircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.Circuit, fields='__all__', - filters=CircuitFilter + filters=CircuitFilter, + pagination=True ) class CircuitType(NetBoxObjectType, ContactsMixin): provider: ProviderType @@ -109,7 +119,8 @@ class CircuitType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.CircuitGroup, fields='__all__', - filters=CircuitGroupFilter + filters=CircuitGroupFilter, + pagination=True ) class CircuitGroupType(OrganizationalObjectType): tenant: TenantType | None @@ -117,8 +128,9 @@ class CircuitGroupType(OrganizationalObjectType): @strawberry_django.type( models.CircuitGroupAssignment, - exclude=('member_type', 'member_id'), - filters=CircuitGroupAssignmentFilter + exclude=['member_type', 'member_id'], + filters=CircuitGroupAssignmentFilter, + pagination=True ) class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')] @@ -134,7 +146,8 @@ class CircuitGroupAssignmentType(TagsMixin, BaseObjectType): @strawberry_django.type( models.VirtualCircuitType, fields='__all__', - filters=VirtualCircuitTypeFilter + filters=VirtualCircuitTypeFilter, + pagination=True ) class VirtualCircuitTypeType(OrganizationalObjectType): color: str @@ -145,7 +158,8 @@ class VirtualCircuitTypeType(OrganizationalObjectType): @strawberry_django.type( models.VirtualCircuitTermination, fields='__all__', - filters=VirtualCircuitTerminationFilter + filters=VirtualCircuitTerminationFilter, + pagination=True ) class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): virtual_circuit: Annotated[ @@ -161,7 +175,8 @@ class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.VirtualCircuit, fields='__all__', - filters=VirtualCircuitFilter + filters=VirtualCircuitFilter, + pagination=True ) class VirtualCircuitType(NetBoxObjectType): provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"]) diff --git a/netbox/circuits/migrations/0002_squashed_0029.py b/netbox/circuits/migrations/0002_squashed_0029.py index cb61d8feb..0062575cd 100644 --- a/netbox/circuits/migrations/0002_squashed_0029.py +++ b/netbox/circuits/migrations/0002_squashed_0029.py @@ -5,11 +5,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), ('contenttypes', '0002_remove_content_type_name'), - ('circuits', '0001_initial'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('circuits', '0001_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/circuits/migrations/0038_squashed_0042.py b/netbox/circuits/migrations/0038_squashed_0042.py index fa944b763..be07638b4 100644 --- a/netbox/circuits/migrations/0038_squashed_0042.py +++ b/netbox/circuits/migrations/0038_squashed_0042.py @@ -15,8 +15,8 @@ class Migration(migrations.Migration): ] dependencies = [ - ('circuits', '0037_new_cabling_models'), - ('dcim', '0160_populate_cable_ends'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py index 6c4dffeb6..400c419ef 100644 --- a/netbox/circuits/migrations/0043_circuittype_color.py +++ b/netbox/circuits/migrations/0043_circuittype_color.py @@ -6,7 +6,7 @@ import utilities.fields class Migration(migrations.Migration): dependencies = [ - ('circuits', '0042_provideraccount'), + ('circuits', '0038_squashed_0042'), ] operations = [ diff --git a/netbox/circuits/migrations/0047_circuittermination__termination.py b/netbox/circuits/migrations/0047_circuittermination__termination.py index f78e17ec3..4caa3a37d 100644 --- a/netbox/circuits/migrations/0047_circuittermination__termination.py +++ b/netbox/circuits/migrations/0047_circuittermination__termination.py @@ -39,9 +39,6 @@ class Migration(migrations.Migration): name='termination_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('model__in', ('region', 'sitegroup', 'site', 'location', 'providernetwork')) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py index f8c0fd653..0418c26e5 100644 --- a/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py +++ b/netbox/circuits/migrations/0051_virtualcircuit_group_assignment.py @@ -51,7 +51,6 @@ class Migration(migrations.Migration): name='member_type', field=models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), related_name='+', to='contenttypes.contenttype', blank=True, @@ -68,7 +67,6 @@ class Migration(migrations.Migration): model_name='circuitgroupassignment', name='member_type', field=models.ForeignKey( - limit_choices_to=models.Q(('app_label', 'circuits'), ('model__in', ['circuit', 'virtualcircuit'])), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype' diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 9c7714153..102377296 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -182,7 +182,6 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, """ member_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CIRCUIT_GROUP_ASSIGNMENT_MEMBER_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -249,7 +248,6 @@ class CircuitTermination( termination_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=Q(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES), related_name='+', blank=True, null=True diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 3bd81c33a..644251c35 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,7 +5,6 @@ from django.utils.translation import gettext_lazy as _ from dcim.views import PathTraceView from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view @@ -74,11 +73,6 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable -@register_model_view(Provider, 'contacts') -class ProviderContactsView(ObjectContactsView): - queryset = Provider.objects.all() - - # # ProviderAccounts # @@ -141,11 +135,6 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderAccountTable -@register_model_view(ProviderAccount, 'contacts') -class ProviderAccountContactsView(ObjectContactsView): - queryset = ProviderAccount.objects.all() - - # # Provider networks # @@ -413,11 +402,6 @@ class CircuitSwapTerminations(generic.ObjectEditView): }) -@register_model_view(Circuit, 'contacts') -class CircuitContactsView(ObjectContactsView): - queryset = Circuit.objects.all() - - # # Circuit terminations # diff --git a/netbox/core/api/serializers_/data.py b/netbox/core/api/serializers_/data.py index 2c155ba6b..3f2ddb2a0 100644 --- a/netbox/core/api/serializers_/data.py +++ b/netbox/core/api/serializers_/data.py @@ -26,8 +26,8 @@ class DataSourceSerializer(NetBoxModelSerializer): model = DataSource fields = [ 'id', 'url', 'display_url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', - 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', 'last_synced', - 'file_count', + 'sync_interval', 'parameters', 'ignore_rules', 'comments', 'custom_fields', 'created', 'last_updated', + 'last_synced', 'file_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/core/apps.py b/netbox/core/apps.py index b1337c7ed..f74a01aa9 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -19,6 +19,7 @@ class CoreConfig(AppConfig): def ready(self): from core.api import schema # noqa: F401 + from core.checks import check_duplicate_indexes # noqa: F401 from netbox.models.features import register_models from . import data_backends, events, search # noqa: F401 from netbox import context_managers # noqa: F401 diff --git a/netbox/core/checks.py b/netbox/core/checks.py new file mode 100644 index 000000000..cab52a025 --- /dev/null +++ b/netbox/core/checks.py @@ -0,0 +1,41 @@ +from django.core.checks import Error, register, Tags +from django.db.models import Index, UniqueConstraint +from django.apps import apps + +__all__ = ( + 'check_duplicate_indexes', +) + + +@register(Tags.models) +def check_duplicate_indexes(app_configs, **kwargs): + """ + Check for an index which is redundant to a declared unique constraint. + """ + errors = [] + + for model in apps.get_models(): + if not (meta := getattr(model, "_meta", None)): + continue + + index_fields = { + tuple(index.fields) for index in getattr(meta, 'indexes', []) + if isinstance(index, Index) + } + constraint_fields = { + tuple(constraint.fields) for constraint in getattr(meta, 'constraints', []) + if isinstance(constraint, UniqueConstraint) + } + + # Find overlapping definitions + if duplicated := index_fields & constraint_fields: + for fields in duplicated: + errors.append( + Error( + f"Model '{model.__name__}' defines the same field set {fields} in both `Meta.indexes` and " + f"`Meta.constraints`.", + obj=model, + ) + ) + + return errors diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 770a3b258..9ba1d5dfd 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -7,13 +7,13 @@ from pathlib import Path from urllib.parse import urlparse from django import forms -from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext as _ from netbox.data_backends import DataBackend from netbox.utils import register_data_backend from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS +from utilities.proxy import resolve_proxies from utilities.socks import ProxyPoolManager from .exceptions import SyncError @@ -70,18 +70,18 @@ class GitBackend(DataBackend): # Initialize backend config config = ConfigDict() - self.use_socks = False + self.socks_proxy = None # Apply HTTP proxy (if configured) - if settings.HTTP_PROXIES: - if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None): - if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: - raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") + proxies = resolve_proxies(url=self.url, context={'client': self}) or {} + if proxy := proxies.get(self.url_scheme): + if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS: + raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}") - if self.url_scheme in ('http', 'https'): - config.set("http", "proxy", proxy) - if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: - self.use_socks = True + if self.url_scheme in ('http', 'https'): + config.set("http", "proxy", proxy) + if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS: + self.socks_proxy = proxy return config @@ -98,8 +98,8 @@ class GitBackend(DataBackend): } # check if using socks for proxy - if so need to use custom pool_manager - if self.use_socks: - clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme)) + if self.socks_proxy: + clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy) if self.url_scheme in ('http', 'https'): if self.params.get('username'): @@ -147,7 +147,7 @@ class S3Backend(DataBackend): # Initialize backend config return Boto3Config( - proxies=settings.HTTP_PROXIES, + proxies=resolve_proxies(url=self.url, context={'client': self}), ) @contextmanager diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py index 8412b0378..5790704c2 100644 --- a/netbox/core/exceptions.py +++ b/netbox/core/exceptions.py @@ -1,2 +1,9 @@ +from django.core.exceptions import ImproperlyConfigured + + class SyncError(Exception): pass + + +class IncompatiblePluginError(ImproperlyConfigured): + pass diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 21fdaa4ab..42ec22350 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -29,6 +29,10 @@ class DataSourceFilterSet(NetBoxModelFilterSet): choices=DataSourceStatusChoices, null_value=None ) + sync_interval = django_filters.MultipleChoiceFilter( + choices=JobIntervalChoices, + null_value=None + ) class Meta: model = DataSource diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index c1f1fca4d..73618826d 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -1,6 +1,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ +from core.choices import JobIntervalChoices from core.models import * from netbox.forms import NetBoxModelBulkEditForm from netbox.utils import get_data_backend_choices @@ -29,6 +30,11 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + sync_interval = forms.ChoiceField( + choices=JobIntervalChoices, + required=False, + label=_('Sync interval') + ) comments = CommentField() parameters = forms.JSONField( label=_('Parameters'), @@ -42,8 +48,8 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm): model = DataSource fieldsets = ( - FieldSet('type', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules'), + FieldSet('type', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', 'comments'), ) nullable_fields = ( - 'description', 'description', 'parameters', 'comments', 'parameters', 'ignore_rules', + 'description', 'description', 'sync_interval', 'parameters', 'parameters', 'ignore_rules' 'comments', ) diff --git a/netbox/core/forms/bulk_import.py b/netbox/core/forms/bulk_import.py index 78a859dcb..a5791c945 100644 --- a/netbox/core/forms/bulk_import.py +++ b/netbox/core/forms/bulk_import.py @@ -11,5 +11,6 @@ class DataSourceImportForm(NetBoxModelImportForm): class Meta: model = DataSource fields = ( - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'parameters', 'ignore_rules', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'parameters', 'ignore_rules', + 'comments', ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index ab4b869b7..4e7286737 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -27,7 +27,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): model = DataSource fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('type', 'status', name=_('Data Source')), + FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')), ) type = forms.MultipleChoiceField( label=_('Type'), @@ -46,6 +46,11 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + sync_interval = forms.ChoiceField( + label=_('Sync interval'), + choices=JobIntervalChoices, + required=False + ) class DataFileFilterForm(NetBoxModelFilterSetForm): diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index a05377597..0a683a381 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -36,7 +36,7 @@ class DataSourceForm(NetBoxModelForm): class Meta: model = DataSource fields = [ - 'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags', + 'name', 'type', 'source_url', 'enabled', 'description', 'sync_interval', 'ignore_rules', 'comments', 'tags', ] widgets = { 'ignore_rules': forms.Textarea( @@ -51,7 +51,10 @@ class DataSourceForm(NetBoxModelForm): @property def fieldsets(self): fieldsets = [ - FieldSet('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules', name=_('Source')), + FieldSet( + 'name', 'type', 'source_url', 'description', 'tags', 'ignore_rules', name=_('Source') + ), + FieldSet('enabled', 'sync_interval', name=_('Sync')), ] if self.backend_fields: fieldsets.append( diff --git a/netbox/core/graphql/filter_mixins.py b/netbox/core/graphql/filter_mixins.py new file mode 100644 index 000000000..670ec2ebb --- /dev/null +++ b/netbox/core/graphql/filter_mixins.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import DatetimeFilterLookup + +if TYPE_CHECKING: + from .filters import * + +__all__ = ( + 'BaseFilterMixin', + 'BaseObjectTypeFilterMixin', + 'ChangeLogFilterMixin', +) + + +# @strawberry.input +class BaseFilterMixin: ... + + +@dataclass +class BaseObjectTypeFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + + +@dataclass +class ChangeLogFilterMixin(BaseFilterMixin): + id: ID | None = strawberry.UNSET + changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py index 82da685a5..e5d44674a 100644 --- a/netbox/core/graphql/filters.py +++ b/netbox/core/graphql/filters.py @@ -1,28 +1,89 @@ -import strawberry_django +from datetime import datetime +from typing import Annotated, TYPE_CHECKING -from core import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType +from strawberry.scalars import ID +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core import models +from core.graphql.filter_mixins import BaseFilterMixin +from netbox.graphql.filter_mixins import PrimaryModelFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter + from users.graphql.filters import UserFilter __all__ = ( 'DataFileFilter', 'DataSourceFilter', 'ObjectChangeFilter', + 'ContentTypeFilter', ) @strawberry_django.filter(models.DataFile, lookups=True) -@autotype_decorator(filtersets.DataFileFilterSet) class DataFileFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + source_id: ID | None = strawberry_django.filter_field() + path: FilterLookup[str] | None = strawberry_django.filter_field() + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + hash: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DataSource, lookups=True) -@autotype_decorator(filtersets.DataSourceFilterSet) -class DataSourceFilter(BaseFilterMixin): - pass +class DataSourceFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: FilterLookup[str] | None = strawberry_django.filter_field() + source_url: FilterLookup[str] | None = strawberry_django.filter_field() + status: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + ignore_rules: FilterLookup[str] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + last_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + datafiles: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ObjectChange, lookups=True) -@autotype_decorator(filtersets.ObjectChangeFilterSet) class ObjectChangeFilter(BaseFilterMixin): - pass + id: ID | None = strawberry_django.filter_field() + time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_name: FilterLookup[str] | None = strawberry_django.filter_field() + request_id: FilterLookup[str] | None = strawberry_django.filter_field() + action: FilterLookup[str] | None = strawberry_django.filter_field() + changed_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + changed_object_type_id: ID | None = strawberry_django.filter_field() + changed_object_id: ID | None = strawberry_django.filter_field() + related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + related_object_id: ID | None = strawberry_django.filter_field() + object_repr: FilterLookup[str] | None = strawberry_django.filter_field() + prechange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + postchange_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@strawberry_django.filter(DjangoContentType, lookups=True) +class ContentTypeFilter(BaseFilterMixin): + id: ID | None = strawberry_django.filter_field() + app_label: FilterLookup[str] | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/core/graphql/mixins.py b/netbox/core/graphql/mixins.py index 5195b52a0..72191e6fd 100644 --- a/netbox/core/graphql/mixins.py +++ b/netbox/core/graphql/mixins.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -6,6 +6,9 @@ from django.contrib.contenttypes.models import ContentType from core.models import ObjectChange +if TYPE_CHECKING: + from netbox.core.graphql.types import ObjectChangeType + __all__ = ( 'ChangelogMixin', ) diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 09385d7c1..ffaa24411 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -2,12 +2,14 @@ from typing import Annotated, List import strawberry import strawberry_django +from django.contrib.contenttypes.models import ContentType as DjangoContentType from core import models from netbox.graphql.types import BaseObjectType, NetBoxObjectType from .filters import * __all__ = ( + 'ContentType', 'DataFileType', 'DataSourceType', 'ObjectChangeType', @@ -17,7 +19,8 @@ __all__ = ( @strawberry_django.type( models.DataFile, exclude=['data',], - filters=DataFileFilter + filters=DataFileFilter, + pagination=True ) class DataFileType(BaseObjectType): source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] @@ -26,7 +29,8 @@ class DataFileType(BaseObjectType): @strawberry_django.type( models.DataSource, fields='__all__', - filters=DataSourceFilter + filters=DataSourceFilter, + pagination=True ) class DataSourceType(NetBoxObjectType): @@ -36,7 +40,17 @@ class DataSourceType(NetBoxObjectType): @strawberry_django.type( models.ObjectChange, fields='__all__', - filters=ObjectChangeFilter + filters=ObjectChangeFilter, + pagination=True ) class ObjectChangeType(BaseObjectType): pass + + +@strawberry_django.type( + DjangoContentType, + fields='__all__', + pagination=True +) +class ContentType: + pass diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index 891b1cbdb..b3dfaf1e7 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -5,6 +5,7 @@ import sys from django.conf import settings from netbox.jobs import JobRunner, system_job from netbox.search.backends import search_backend +from utilities.proxy import resolve_proxies from .choices import DataSourceStatusChoices, JobIntervalChoices from .exceptions import SyncError from .models import DataSource @@ -71,7 +72,7 @@ class SystemHousekeepingJob(JobRunner): url=settings.CENSUS_URL, params=census_data, timeout=3, - proxies=settings.HTTP_PROXIES + proxies=resolve_proxies(url=settings.CENSUS_URL) ) except requests.exceptions.RequestException: pass diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py index 7c9914298..6a7fe2521 100644 --- a/netbox/core/migrations/0006_datasource_type_remove_choices.py +++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0005_job_created_auto_now'), + ('core', '0001_squashed_0005'), ] operations = [ diff --git a/netbox/core/migrations/0013_datasource_sync_interval.py b/netbox/core/migrations/0013_datasource_sync_interval.py new file mode 100644 index 000000000..ec3d2a5d6 --- /dev/null +++ b/netbox/core/migrations/0013_datasource_sync_interval.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-02-26 19:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_job_object_type_optional'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='sync_interval', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/core/migrations/0014_remove_redundant_indexes.py b/netbox/core/migrations/0014_remove_redundant_indexes.py new file mode 100644 index 000000000..fc90a67fa --- /dev/null +++ b/netbox/core/migrations/0014_remove_redundant_indexes.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_datasource_sync_interval'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='autosyncrecord', + name='core_autosy_object__c17bac_idx', + ), + migrations.RemoveIndex( + model_name='datafile', + name='core_datafile_source_path', + ), + migrations.RemoveIndex( + model_name='managedfile', + name='core_managedfile_root_path', + ), + ] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 39ee8fa57..52a11c58e 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -59,6 +59,12 @@ class DataSource(JobsMixin, PrimaryModel): verbose_name=_('enabled'), default=True ) + sync_interval = models.PositiveSmallIntegerField( + verbose_name=_('sync interval'), + choices=JobIntervalChoices, + blank=True, + null=True + ) ignore_rules = models.TextField( verbose_name=_('ignore rules'), blank=True, @@ -304,9 +310,6 @@ class DataFile(models.Model): name='%(app_label)s_%(class)s_unique_source_path' ), ) - indexes = [ - models.Index(fields=('source', 'path'), name='core_datafile_source_path'), - ] verbose_name = _('data file') verbose_name_plural = _('data files') @@ -351,17 +354,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): """ @@ -392,8 +384,5 @@ class AutoSyncRecord(models.Model): name='%(app_label)s_%(class)s_object' ), ) - indexes = ( - models.Index(fields=('object_type', 'object_id')), - ) verbose_name = _('auto sync record') verbose_name_plural = _('auto sync records') diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index cc446bac7..d60269b8b 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -1,13 +1,16 @@ import logging import os +from functools import cached_property from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.core.files.storage import storages from django.urls import reverse from django.utils.translation import gettext as _ from ..choices import ManagedFileRootPathChoices +from extras.storage import ScriptFileSystemStorage from netbox.models.features import SyncedDataMixin from utilities.querysets import RestrictedQuerySet @@ -55,9 +58,6 @@ class ManagedFile(SyncedDataMixin, models.Model): name='%(app_label)s_%(class)s_unique_root_path' ), ) - indexes = [ - models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'), - ] verbose_name = _('managed file') verbose_name_plural = _('managed files') @@ -76,15 +76,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 +124,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def delete(self, *args, **kwargs): # Delete file from disk + storage = self.storage try: - os.remove(self.full_path) + storage.delete(self.full_path) except FileNotFoundError: pass diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index e6d09711f..42930bbc8 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -11,6 +11,7 @@ from django.core.cache import cache from netbox.plugins import PluginConfig from netbox.registry import registry from utilities.datetime import datetime_from_timestamp +from utilities.proxy import resolve_proxies USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed' @@ -64,9 +65,11 @@ class Plugin: is_certified: bool = False release_latest: PluginVersion = field(default_factory=PluginVersion) release_recent_history: list[PluginVersion] = field(default_factory=list) - is_local: bool = False # extra field for locally installed plugins - is_installed: bool = False + is_local: bool = False # Indicates that the plugin is listed in settings.PLUGINS (i.e. installed) + is_loaded: bool = False # Indicates whether the plugin successfully loaded at launch installed_version: str = '' + netbox_min_version: str = '' + netbox_max_version: str = '' def get_local_plugins(plugins=None): @@ -77,7 +80,7 @@ def get_local_plugins(plugins=None): local_plugins = {} # Gather all locally-installed plugins - for plugin_name in registry['plugins']['installed']: + for plugin_name in settings.PLUGINS: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config installed_version = plugin_config.version @@ -91,19 +94,28 @@ def get_local_plugins(plugins=None): tag_line=plugin_config.description, description_short=plugin_config.description, is_local=True, - is_installed=True, + is_loaded=plugin_name in registry['plugins']['installed'], installed_version=installed_version, + netbox_min_version=plugin_config.min_version, + netbox_max_version=plugin_config.max_version, ) # Update catalog entries for local plugins, or add them to the list if not listed for k, v in local_plugins.items(): if k in plugins: - plugins[k].is_local = True - plugins[k].is_installed = True + plugins[k].is_local = v.is_local + plugins[k].is_loaded = v.is_loaded plugins[k].installed_version = v.installed_version else: plugins[k] = v + # Update plugin table config for hidden and static plugins + hidden = settings.PLUGINS_CATALOG_CONFIG.get('hidden', []) + static = settings.PLUGINS_CATALOG_CONFIG.get('static', []) + for k, v in plugins.items(): + v.hidden = k in hidden + v.static = k in static + return plugins @@ -120,10 +132,11 @@ def get_catalog_plugins(): def get_pages(): # TODO: pagination is currently broken in API payload = {'page': '1', 'per_page': '50'} + proxies = resolve_proxies(url=settings.PLUGIN_CATALOG_URL) first_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() @@ -135,7 +148,7 @@ def get_catalog_plugins(): next_page = session.get( settings.PLUGIN_CATALOG_URL, headers={'User-Agent': USER_AGENT_STRING}, - proxies=settings.HTTP_PROXIES, + proxies=proxies, timeout=3, params=payload ).json() diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 06432bf4c..bdaa60f97 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -8,16 +8,15 @@ from django.dispatch import receiver, Signal from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates -from core.choices import ObjectChangeActionChoices +from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * -from core.models import ObjectChange from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin from utilities.exceptions import AbortRequest -from .models import ConfigRevision +from .models import ConfigRevision, DataSource, ObjectChange __all__ = ( 'clear_events', @@ -182,6 +181,25 @@ def clear_events_queue(sender, **kwargs): # DataSource handlers # +@receiver(post_save, sender=DataSource) +def enqueue_sync_job(instance, created, **kwargs): + """ + When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate. + """ + from .jobs import SyncDataSourceJob + + if instance.enabled and instance.sync_interval: + SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval) + elif not created: + # Delete any previously scheduled recurring jobs for this DataSource + for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter( + interval__isnull=False, + status=JobStatusChoices.STATUS_SCHEDULED + ): + # Call delete() per instance to ensure the associated background task is deleted as well + job.delete() + + @receiver(post_sync) def auto_sync(instance, **kwargs): """ diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py index 4059ea9bc..5d237c689 100644 --- a/netbox/core/tables/data.py +++ b/netbox/core/tables/data.py @@ -25,6 +25,9 @@ class DataSourceTable(NetBoxTable): enabled = columns.BooleanColumn( verbose_name=_('Enabled'), ) + sync_interval = columns.ChoiceFieldColumn( + verbose_name=_('Sync interval'), + ) tags = columns.TagColumn( url_name='core:datasource_list' ) @@ -35,10 +38,10 @@ class DataSourceTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = DataSource fields = ( - 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', - 'created', 'last_updated', 'file_count', + 'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'sync_interval', 'comments', + 'parameters', 'created', 'last_updated', 'file_count', ) - default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count') + default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'sync_interval', 'file_count') class DataFileTable(NetBoxTable): diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 96c612366..20bd0eee6 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -1,7 +1,10 @@ import django_tables2 as tables +from django.urls import reverse +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from netbox.tables import BaseTable, columns +from .template_code import PLUGIN_IS_INSTALLED __all__ = ( 'CatalogPluginTable', @@ -40,20 +43,22 @@ class PluginVersionTable(BaseTable): class CatalogPluginTable(BaseTable): title_long = tables.Column( - linkify=('core:plugin', [tables.A('config_name')]), - verbose_name=_('Name') + verbose_name=_('Name'), ) author = tables.Column( accessor=tables.A('author__name'), verbose_name=_('Author') ) is_local = columns.BooleanColumn( + false_mark=None, verbose_name=_('Local') ) - is_installed = columns.BooleanColumn( - verbose_name=_('Installed') + is_installed = columns.TemplateColumn( + verbose_name=_('Active'), + template_code=PLUGIN_IS_INSTALLED ) is_certified = columns.BooleanColumn( + false_mark=None, verbose_name=_('Certified') ) created_at = columns.DateTimeColumn( @@ -82,3 +87,9 @@ class CatalogPluginTable(BaseTable): # List installed plugins first, then certified plugins, then # everything else (with each tranche ordered alphabetically) order_by = ('-is_installed', '-is_certified', 'name') + + def render_title_long(self, value, record): + if record.static: + return value + url = reverse('core:plugin', args=[record.config_name]) + return mark_safe(f"{value}") diff --git a/netbox/core/tables/template_code.py b/netbox/core/tables/template_code.py index c8f0058e7..9fc652c4c 100644 --- a/netbox/core/tables/template_code.py +++ b/netbox/core/tables/template_code.py @@ -14,3 +14,15 @@ OBJECTCHANGE_OBJECT = """ OBJECTCHANGE_REQUEST_ID = """ {{ value }} """ + +PLUGIN_IS_INSTALLED = """ +{% if record.is_local %} + {% if record.is_loaded %} + + {% else %} + + {% endif %} +{% else %} + — +{% endif %} +""" diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index 310be1d0e..b7dfd516e 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -27,7 +27,8 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source1/', status=DataSourceStatusChoices.NEW, enabled=True, - description='foobar1' + description='foobar1', + sync_interval=JobIntervalChoices.INTERVAL_HOURLY ), DataSource( name='Data Source 2', @@ -35,14 +36,16 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): source_url='file:///var/tmp/source2/', status=DataSourceStatusChoices.SYNCING, enabled=True, - description='foobar2' + description='foobar2', + sync_interval=JobIntervalChoices.INTERVAL_DAILY ), DataSource( name='Data Source 3', type='git', source_url='https://example.com/git/source3', status=DataSourceStatusChoices.COMPLETED, - enabled=False + enabled=False, + sync_interval=JobIntervalChoices.INTERVAL_WEEKLY ), ) DataSource.objects.bulk_create(data_sources) @@ -73,6 +76,10 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'status': [DataSourceStatusChoices.NEW, DataSourceStatusChoices.SYNCING]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_sync_interval(self): + params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DataFile.objects.all() diff --git a/netbox/core/views.py b/netbox/core/views.py index 1f8bff923..c63aa3f81 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -613,6 +613,8 @@ class PluginListView(BasePluginView): if q: plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] + plugins = [plugin for plugin in plugins if not plugin.hidden] + table = CatalogPluginTable(plugins, user=request.user) table.configure(request) diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index a6767bb6f..8b9cd42df 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -1,3 +1,4 @@ +from django.utils.translation import gettext as _ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -155,10 +156,10 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', - 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', - 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color', + 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') @@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect def validate(self, data): - # Validate many-to-many VLAN assignments if not self.nested: + + # Validate 802.1q mode and vlan(s) + mode = None + tagged_vlans = [] + + # Gather Information + if self.instance: + mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \ + self.instance.untagged_vlan + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \ + self.instance.qinq_svlan + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \ + self.instance.tagged_vlans.all() + else: + mode = data.get('mode', None) + untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None + qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None + tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None + + errors = {} + + # Non Q-in-Q mode with service vlan set + if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan: + errors.update({ + 'qinq_svlan': _("Interface mode does not support q-in-q service vlan") + }) + # Routed mode + if not mode: + # Untagged vlan + if untagged_vlan: + errors.update({ + 'untagged_vlan': _("Interface mode does not support untagged vlan") + }) + # Tagged vlan + if tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + # Non-tagged mode + elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans: + errors.update({ + 'tagged_vlans': _("Interface mode does not support tagged vlans") + }) + + if errors: + raise serializers.ValidationError(errors) + + # Validate many-to-many VLAN assignments device = self.instance.device if self.instance else data.get('device') for vlan in data.get('tagged_vlans', []): if vlan.site not in [device.site, None]: diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 0ce2af2f8..61e3833ec 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -4,8 +4,8 @@ from django.utils.translation import gettext as _ from rest_framework import serializers from dcim.choices import * -from dcim.models import DeviceType, ModuleType -from netbox.api.fields import ChoiceField, RelatedObjectCountField +from dcim.models import DeviceType, ModuleType, ModuleTypeProfile +from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from netbox.choices import * from .manufacturers import ManufacturerSerializer @@ -13,6 +13,7 @@ from .platforms import PlatformSerializer __all__ = ( 'DeviceTypeSerializer', + 'ModuleTypeProfileSerializer', 'ModuleTypeSerializer', ) @@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer): brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count') +class ModuleTypeProfileSerializer(NetBoxModelSerializer): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + brief_fields = ('id', 'url', 'display', 'name', 'description') + + class ModuleTypeSerializer(NetBoxModelSerializer): + profile = ModuleTypeProfileSerializer( + nested=True, + required=False, + allow_null=True + ) manufacturer = ManufacturerSerializer( nested=True ) @@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer): required=False, allow_null=True ) + attributes = AttributesField( + source='attribute_data', + required=False, + allow_null=True + ) class Meta: model = ModuleType fields = [ - 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow', - 'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', + 'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description') + brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description') diff --git a/netbox/dcim/api/serializers_/nested.py b/netbox/dcim/api/serializers_/nested.py index ea346cc63..0e9eaa52f 100644 --- a/netbox/dcim/api/serializers_/nested.py +++ b/netbox/dcim/api/serializers_/nested.py @@ -52,6 +52,13 @@ class NestedLocationSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count', '_depth'] +class NestedDeviceRoleSerializer(WritableNestedSerializer): + + class Meta: + model = models.DeviceRole + fields = ['id', 'url', 'display_url', 'display', 'name'] + + class NestedDeviceSerializer(WritableNestedSerializer): class Meta: diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 1378c265a..4bc2900dc 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -70,8 +70,8 @@ class RackTypeSerializer(RackBaseSerializer): model = RackType fields = [ 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', - 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight', - 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', + 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') @@ -129,9 +129,9 @@ class RackSerializer(RackBaseSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight', - 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', - 'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', - 'powerfeed_count', + 'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', + 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'powerfeed_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count') diff --git a/netbox/dcim/api/serializers_/roles.py b/netbox/dcim/api/serializers_/roles.py index 8f922da10..17eeaa949 100644 --- a/netbox/dcim/api/serializers_/roles.py +++ b/netbox/dcim/api/serializers_/roles.py @@ -1,7 +1,8 @@ from dcim.models import DeviceRole, InventoryItemRole from extras.api.serializers_.configtemplates import ConfigTemplateSerializer from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import NetBoxModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from .nested import NestedDeviceRoleSerializer __all__ = ( 'DeviceRoleSerializer', @@ -9,7 +10,8 @@ __all__ = ( ) -class DeviceRoleSerializer(NetBoxModelSerializer): +class DeviceRoleSerializer(NestedGroupModelSerializer): + parent = NestedDeviceRoleSerializer(required=False, allow_null=True, default=None) config_template = ConfigTemplateSerializer(nested=True, required=False, allow_null=True, default=None) # Related object counts @@ -19,10 +21,13 @@ class DeviceRoleSerializer(NetBoxModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'parent', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'comments', '_depth', ] - brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count') + brief_fields = ( + 'id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count', '_depth' + ) class InventoryItemRoleSerializer(NetBoxModelSerializer): diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index b818cd954..90f7b5d35 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -27,7 +27,7 @@ class RegionSerializer(NestedGroupModelSerializer): model = Region fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -41,7 +41,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): model = SiteGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -93,6 +93,6 @@ class LocationSerializer(NestedGroupModelSerializer): fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', - 'prefix_count', '_depth', + 'prefix_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fc3740374..734ac13db 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet) router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) router.register('module-types', views.ModuleTypeViewSet) +router.register('module-type-profiles', views.ModuleTypeProfileViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7dbbef91..575ee770f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet): filterset_class = filtersets.DeviceTypeFilterSet +class ModuleTypeProfileViewSet(NetBoxModelViewSet): + queryset = ModuleTypeProfile.objects.all() + serializer_class = serializers.ModuleTypeProfileSerializer + filterset_class = filtersets.ModuleTypeProfileFilterSet + + class ModuleTypeViewSet(NetBoxModelViewSet): queryset = ModuleType.objects.all() serializer_class = serializers.ModuleTypeSerializer diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c5b6cbcad..47d774bd2 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -128,14 +128,15 @@ class RackElevationDetailRenderChoices(ChoiceSet): class RackAirflowChoices(ChoiceSet): + key = 'Rack.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), - ) + ] # @@ -191,6 +192,7 @@ class DeviceStatusChoices(ChoiceSet): class DeviceAirflowChoices(ChoiceSet): + key = 'Device.airflow' AIRFLOW_FRONT_TO_REAR = 'front-to-rear' AIRFLOW_REAR_TO_FRONT = 'rear-to-front' @@ -203,7 +205,7 @@ class DeviceAirflowChoices(ChoiceSet): AIRFLOW_PASSIVE = 'passive' AIRFLOW_MIXED = 'mixed' - CHOICES = ( + CHOICES = [ (AIRFLOW_FRONT_TO_REAR, _('Front to rear')), (AIRFLOW_REAR_TO_FRONT, _('Rear to front')), (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')), @@ -214,7 +216,7 @@ class DeviceAirflowChoices(ChoiceSet): (AIRFLOW_TOP_TO_BOTTOM, _('Top to bottom')), (AIRFLOW_PASSIVE, _('Passive')), (AIRFLOW_MIXED, _('Mixed')), - ) + ] # @@ -242,6 +244,7 @@ class ModuleStatusChoices(ChoiceSet): class ModuleAirflowChoices(ChoiceSet): + key = 'Module.airflow' FRONT_TO_REAR = 'front-to-rear' REAR_TO_FRONT = 'rear-to-front' @@ -250,14 +253,14 @@ class ModuleAirflowChoices(ChoiceSet): SIDE_TO_REAR = 'side-to-rear' PASSIVE = 'passive' - CHOICES = ( + CHOICES = [ (FRONT_TO_REAR, _('Front to rear')), (REAR_TO_FRONT, _('Rear to front')), (LEFT_TO_RIGHT, _('Left to right')), (RIGHT_TO_LEFT, _('Right to left')), (SIDE_TO_REAR, _('Side to rear')), (PASSIVE, _('Passive')), - ) + ] # @@ -1627,6 +1630,23 @@ class PowerFeedPhaseChoices(ChoiceSet): ) +# +# PowerOutlets +# +class PowerOutletStatusChoices(ChoiceSet): + key = 'PowerOutlet.status' + + STATUS_ENABLED = 'enabled' + STATUS_DISABLED = 'disabled' + STATUS_FAULTY = 'faulty' + + CHOICES = [ + (STATUS_ENABLED, _('Enabled'), 'green'), + (STATUS_DISABLED, _('Disabled'), 'red'), + (STATUS_FAULTY, _('Faulty'), 'gray'), + ] + + # # VDC # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 60c3c4d38..fed660c1f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,8 @@ from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF from netbox.choices import ColorChoices from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, + AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet, + OrganizationalModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * @@ -58,6 +59,7 @@ __all__ = ( 'ModuleBayTemplateFilterSet', 'ModuleFilterSet', 'ModuleTypeFilterSet', + 'ModuleTypeProfileFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -81,7 +83,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 +113,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 +207,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 +277,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): @@ -312,8 +314,8 @@ class RackTypeFilterSet(NetBoxModelFilterSet): class Meta: model = RackType fields = ( - 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', + 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', ) def search(self, queryset, name, value): @@ -425,8 +427,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe model = Rack fields = ( 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', ) def search(self, queryset, name, value): @@ -673,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): return queryset.exclude(inventoryitemtemplates__isnull=value) -class ModuleTypeFilterSet(NetBoxModelFilterSet): +class ModuleTypeProfileFilterSet(NetBoxModelFilterSet): + + class Meta: + model = ModuleTypeProfile + fields = ('id', 'name', 'description') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + +class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet): + profile_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile (ID)'), + ) + profile = django_filters.ModelMultipleChoiceFilter( + field_name='profile__name', + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + label=_('Profile (name)'), + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), label=_('Manufacturer (ID)'), @@ -921,6 +949,29 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet): queryset=ConfigTemplate.objects.all(), label=_('Config template (ID)'), ) + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + label=_('Parent device role (ID)'), + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label=_('Parent device role (slug)'), + ) + ancestor_id = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + label=_('Parent device role (ID)'), + ) + ancestor = TreeNodeMultipleChoiceFilter( + queryset=DeviceRole.objects.all(), + field_name='parent', + lookup_expr='in', + to_field_name='slug', + label=_('Parent device role (slug)'), + ) class Meta: model = DeviceRole @@ -989,14 +1040,16 @@ class DeviceFilterSet( queryset=DeviceType.objects.all(), label=_('Device type (ID)'), ) - role_id = django_filters.ModelMultipleChoiceFilter( - field_name='role_id', + role_id = TreeNodeMultipleChoiceFilter( + field_name='role', queryset=DeviceRole.objects.all(), + lookup_expr='in', label=_('Role (ID)'), ) - role = django_filters.ModelMultipleChoiceFilter( - field_name='role__slug', + role = TreeNodeMultipleChoiceFilter( queryset=DeviceRole.objects.all(), + field_name='role', + lookup_expr='in', to_field_name='slug', label=_('Role (slug)'), ) @@ -1591,11 +1644,15 @@ class PowerOutletFilterSet( queryset=PowerPort.objects.all(), label=_('Power port (ID)'), ) + status = django_filters.MultipleChoiceFilter( + choices=PowerOutletStatusChoices, + null_value=None + ) class Meta: model = PowerOutlet fields = ( - 'id', 'name', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', + 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index da5a45f15..dd6c4a791 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model -from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.fields import ( + ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, +) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from virtualization.models import Cluster @@ -46,6 +48,7 @@ __all__ = ( 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', + 'ModuleTypeProfileBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -78,12 +81,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 +101,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 +202,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): @@ -257,6 +263,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -299,7 +310,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', name=_('Rack Type')), FieldSet( - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') @@ -307,7 +318,7 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm): FieldSet('starting_unit', 'desc_units', name=_('Numbering')), ) nullable_fields = ( - 'outer_width', 'outer_depth', 'outer_unit', 'weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -401,6 +412,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False, min_value=1 ) + outer_height = forms.IntegerField( + label=_('Outer height'), + required=False, + min_value=1 + ) outer_depth = forms.IntegerField( label=_('Outer depth'), required=False, @@ -448,15 +464,13 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), - FieldSet( - 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', name=_('Hardware') - ), + FieldSet('outer_width', 'outer_height', 'outer_depth', 'outer_unit', name=_('Outer Dimensions')), + FieldSet('form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'mounting_depth', name=_('Hardware')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), ) nullable_fields = ( - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight', - 'max_weight', 'weight_unit', 'description', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_height', 'outer_depth', + 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', ) @@ -563,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments') +class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm): + schema = JSONField( + label=_('Schema'), + required=False + ) + description = forms.CharField( + label=_('Description'), + max_length=200, + required=False + ) + comments = CommentField() + + model = ModuleTypeProfile + fieldsets = ( + FieldSet('name', 'description', 'schema', name=_('Profile')), + ) + nullable_fields = ('description', 'comments') + + class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): + profile = DynamicModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -598,17 +636,22 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): model = ModuleType fieldsets = ( - FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')), + FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')), FieldSet( 'airflow', InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), name=_('Chassis') ), ) - nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments') + nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments') class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) color = ColorField( label=_('Color'), required=False @@ -628,12 +671,13 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = DeviceRole fieldsets = ( - FieldSet('color', 'vm_role', 'config_template', 'description'), + FieldSet('parent', 'color', 'vm_role', 'config_template', 'description'), ) - nullable_fields = ('color', 'config_template', 'description') + nullable_fields = ('parent', 'color', 'config_template', 'description', 'comments') class PlatformBulkEditForm(NetBoxModelBulkEditForm): @@ -1379,7 +1423,10 @@ class PowerPortBulkEditForm( class PowerOutletBulkEditForm( ComponentBulkEditForm, - form_from_model(PowerOutlet, ['label', 'type', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description']) + form_from_model( + PowerOutlet, + ['label', 'type', 'status', 'color', 'feed_leg', 'power_port', 'mark_connected', 'description'] + ) ): mark_connected = forms.NullBooleanField( label=_('Mark connected'), @@ -1389,7 +1436,7 @@ class PowerOutletBulkEditForm( model = PowerOutlet fieldsets = ( - FieldSet('module', 'type', 'label', 'description', 'mark_connected', 'color'), + FieldSet('module', 'type', 'label', 'status', 'description', 'mark_connected', 'color'), FieldSet('feed_leg', 'power_port', name=_('Power')), ) nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 92f7220da..d412694b3 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -39,6 +39,7 @@ __all__ = ( 'ModuleImportForm', 'ModuleBayImportForm', 'ModuleTypeImportForm', + 'ModuleTypeProfileImportForm', 'PlatformImportForm', 'PowerFeedImportForm', 'PowerOutletImportForm', @@ -68,7 +69,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 +83,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 +161,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) @@ -219,7 +223,7 @@ class RackTypeImportForm(NetBoxModelImportForm): model = RackType fields = ( 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) @@ -304,7 +308,7 @@ class RackImportForm(NetBoxModelImportForm): model = Rack fields = ( 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial', - 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) @@ -424,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm): ] +class ModuleTypeProfileImportForm(NetBoxModelImportForm): + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeImportForm(NetBoxModelImportForm): + profile = forms.ModelChoiceField( + label=_('Profile'), + queryset=ModuleTypeProfile.objects.all(), + to_field_name='name', + required=False + ) manufacturer = forms.ModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), @@ -457,6 +476,16 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm): + parent = CSVModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + to_field_name='name', + help_text=_('Parent Device Role'), + error_messages={ + 'invalid_choice': _('Device role not found.'), + } + ) config_template = CSVModelChoiceField( label=_('Config template'), queryset=ConfigTemplate.objects.all(), @@ -468,7 +497,9 @@ class DeviceRoleImportForm(NetBoxModelImportForm): class Meta: model = DeviceRole - fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags') + fields = ( + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags' + ) class PlatformImportForm(NetBoxModelImportForm): diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 8ca258f34..23109f66b 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form): super().clean() parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data.get('tagged_vlans') - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': _("An access interface cannot have tagged VLANs assigned.") - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] + if 'tagged_vlans' in self.fields.keys(): + tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \ + self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans') + else: + tagged_vlans = [] # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: + if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 37b8afd17..8465f6404 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -39,6 +39,7 @@ __all__ = ( 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', + 'ModuleTypeProfileFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): ) +class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm): + model = ModuleTypeProfile + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + ) + selector_fields = ('filter_id', 'q') + + class ModuleTypeFilterForm(NetBoxModelFilterSetForm): model = ModuleType fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), + FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', name=_('Components') @@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): FieldSet('weight', 'weight_unit', name=_('Weight')), ) selector_fields = ('filter_id', 'q', 'manufacturer_id') + profile_id = DynamicModelMultipleChoiceField( + queryset=ModuleTypeProfile.objects.all(), + required=False, + label=_('Profile') + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -689,6 +703,11 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Config template') ) + parent_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Parent') + ) tag = TagFilterField(model) @@ -1305,7 +1324,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), + FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet( 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', @@ -1323,6 +1342,11 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): label=_('Color'), required=False ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=PowerOutletStatusChoices, + required=False + ) class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 5a3a27d25..ac9b0ea9a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.validators import EMPTY_VALUES from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -18,6 +19,7 @@ from utilities.forms.fields import ( ) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK +from utilities.jsonschema import JSONSchemaProperty from virtualization.models import Cluster, VMInterface from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -48,6 +50,7 @@ __all__ = ( 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', + 'ModuleTypeProfileForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -78,6 +81,7 @@ class RegionForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -86,7 +90,7 @@ class RegionForm(NetBoxModelForm): class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ) @@ -97,6 +101,7 @@ class SiteGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags'), @@ -105,7 +110,7 @@ class SiteGroupForm(NetBoxModelForm): class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'comments', 'tags', ) @@ -179,6 +184,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): } ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('site', 'parent', 'name', 'slug', 'status', 'facility', 'description', 'tags', name=_('Location')), @@ -188,7 +194,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', ) @@ -222,7 +229,7 @@ class RackTypeForm(NetBoxModelForm): FieldSet('manufacturer', 'model', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')), FieldSet( 'width', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', name=_('Dimensions') ), @@ -233,8 +240,8 @@ class RackTypeForm(NetBoxModelForm): model = RackType fields = [ 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] @@ -279,8 +286,8 @@ class RackForm(TenancyForm, NetBoxModelForm): fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', - 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', - 'description', 'comments', 'tags', + 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', + 'weight_unit', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -302,7 +309,8 @@ class RackForm(TenancyForm, NetBoxModelForm): *self.fieldsets, FieldSet( 'form_factor', 'width', 'starting_unit', 'u_height', - InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), + InlineFields('outer_width', 'outer_height', 'outer_depth', 'outer_unit', + label=_('Outer Dimensions')), InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')), 'mounting_depth', 'desc_units', name=_('Dimensions') ), @@ -399,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm): } +class ModuleTypeProfileForm(NetBoxModelForm): + schema = JSONField( + label=_('Schema'), + required=False, + help_text=_("Enter a valid JSON schema to define supported attributes.") + ) + comments = CommentField() + + fieldsets = ( + FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')), + ) + + class Meta: + model = ModuleTypeProfile + fields = [ + 'name', 'description', 'schema', 'comments', 'tags', + ] + + class ModuleTypeForm(NetBoxModelForm): + profile = forms.ModelChoiceField( + queryset=ModuleTypeProfile.objects.all(), + label=_('Profile'), + required=False, + widget=HTMXSelect() + ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all() ) comments = CommentField() - fieldsets = ( - FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), - FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis')) - ) + @property + def fieldsets(self): + return [ + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes')) + ] class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', + 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Track profile-specific attribute fields + self.attr_fields = [] + + # Retrieve assigned ModuleTypeProfile, if any + if not (profile_id := get_field_value(self, 'profile')): + return + if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()): + return + + # Extend form with fields for profile attributes + for attr, form_field in self._get_attr_form_fields(profile).items(): + field_name = f'attr_{attr}' + self.attr_fields.append(field_name) + self.fields[field_name] = form_field + if self.instance.attribute_data: + self.fields[field_name].initial = self.instance.attribute_data.get(attr) + + @staticmethod + def _get_attr_form_fields(profile): + """ + Return a dictionary mapping of attribute names to form fields, suitable for extending + the form per the selected ModuleTypeProfile. + """ + if not profile.schema: + return {} + + properties = profile.schema.get('properties', {}) + required_fields = profile.schema.get('required', []) + + attr_fields = {} + for name, options in properties.items(): + prop = JSONSchemaProperty(**options) + attr_fields[name] = prop.to_form_field(name, required=name in required_fields) + + return dict(sorted(attr_fields.items())) + + def _post_clean(self): + + # Compile attribute data from the individual form fields + if self.cleaned_data.get('profile'): + self.instance.attribute_data = { + name[5:]: self.cleaned_data[name] # Remove the attr_ prefix + for name in self.attr_fields + if self.cleaned_data.get(name) not in EMPTY_VALUES + } + + return super()._post_clean() + class DeviceRoleForm(NetBoxModelForm): config_template = DynamicModelChoiceField( @@ -426,17 +513,24 @@ class DeviceRoleForm(NetBoxModelForm): required=False ) slug = SlugField() + parent = DynamicModelChoiceField( + label=_('Parent'), + queryset=DeviceRole.objects.all(), + required=False, + ) + comments = CommentField() fieldsets = ( FieldSet( - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', name=_('Device Role') + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', + 'tags', name=_('Device Role') ), ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', + 'name', 'slug', 'parent', 'color', 'vm_role', 'config_template', 'description', 'comments', 'tags', ] @@ -1308,7 +1402,7 @@ class PowerOutletForm(ModularDeviceComponentForm): fieldsets = ( FieldSet( - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ), ) @@ -1316,7 +1410,7 @@ class PowerOutletForm(ModularDeviceComponentForm): class Meta: model = PowerOutlet fields = [ - 'device', 'module', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] diff --git a/netbox/dcim/graphql/enums.py b/netbox/dcim/graphql/enums.py new file mode 100644 index 000000000..a60c0df14 --- /dev/null +++ b/netbox/dcim/graphql/enums.py @@ -0,0 +1,77 @@ +import strawberry + +from dcim.choices import * + +__all__ = ( + 'CableEndEnum', + 'CableLengthUnitEnum', + 'CableTypeEnum', + 'ConsolePortSpeedEnum', + 'ConsolePortTypeEnum', + 'DeviceAirflowEnum', + 'DeviceFaceEnum', + 'DeviceStatusEnum', + 'InterfaceDuplexEnum', + 'InterfaceModeEnum', + 'InterfacePoEModeEnum', + 'InterfacePoETypeEnum', + 'InterfaceSpeedEnum', + 'InterfaceTypeEnum', + 'InventoryItemStatusEnum', + 'LinkStatusEnum', + 'LocationStatusEnum', + 'ModuleAirflowEnum', + 'ModuleStatusEnum', + 'PortTypeEnum', + 'PowerFeedPhaseEnum', + 'PowerFeedStatusEnum', + 'PowerFeedSupplyEnum', + 'PowerFeedTypeEnum', + 'PowerOutletFeedLegEnum', + 'PowerOutletTypeEnum', + 'PowerPortTypeEnum', + 'RackAirflowEnum', + 'RackDimensionUnitEnum', + 'RackFormFactorEnum', + 'RackStatusEnum', + 'RackWidthEnum', + 'SiteStatusEnum', + 'SubdeviceRoleEnum', + 'VirtualDeviceContextStatusEnum', +) + +CableEndEnum = strawberry.enum(CableEndChoices.as_enum()) +CableLengthUnitEnum = strawberry.enum(CableLengthUnitChoices.as_enum()) +CableTypeEnum = strawberry.enum(CableTypeChoices.as_enum()) +ConsolePortSpeedEnum = strawberry.enum(ConsolePortSpeedChoices.as_enum()) +ConsolePortTypeEnum = strawberry.enum(ConsolePortTypeChoices.as_enum()) +DeviceAirflowEnum = strawberry.enum(DeviceAirflowChoices.as_enum()) +DeviceFaceEnum = strawberry.enum(DeviceFaceChoices.as_enum()) +DeviceStatusEnum = strawberry.enum(DeviceStatusChoices.as_enum()) +InterfaceDuplexEnum = strawberry.enum(InterfaceDuplexChoices.as_enum()) +InterfaceModeEnum = strawberry.enum(InterfaceModeChoices.as_enum()) +InterfacePoEModeEnum = strawberry.enum(InterfacePoEModeChoices.as_enum()) +InterfacePoETypeEnum = strawberry.enum(InterfacePoETypeChoices.as_enum()) +InterfaceSpeedEnum = strawberry.enum(InterfaceSpeedChoices.as_enum()) +InterfaceTypeEnum = strawberry.enum(InterfaceTypeChoices.as_enum()) +InventoryItemStatusEnum = strawberry.enum(InventoryItemStatusChoices.as_enum()) +LinkStatusEnum = strawberry.enum(LinkStatusChoices.as_enum()) +LocationStatusEnum = strawberry.enum(LocationStatusChoices.as_enum()) +ModuleAirflowEnum = strawberry.enum(ModuleAirflowChoices.as_enum()) +ModuleStatusEnum = strawberry.enum(ModuleStatusChoices.as_enum()) +PortTypeEnum = strawberry.enum(PortTypeChoices.as_enum()) +PowerFeedPhaseEnum = strawberry.enum(PowerFeedPhaseChoices.as_enum()) +PowerFeedStatusEnum = strawberry.enum(PowerFeedStatusChoices.as_enum()) +PowerFeedSupplyEnum = strawberry.enum(PowerFeedSupplyChoices.as_enum()) +PowerFeedTypeEnum = strawberry.enum(PowerFeedTypeChoices.as_enum()) +PowerOutletFeedLegEnum = strawberry.enum(PowerOutletFeedLegChoices.as_enum()) +PowerOutletTypeEnum = strawberry.enum(PowerOutletTypeChoices.as_enum()) +PowerPortTypeEnum = strawberry.enum(PowerPortTypeChoices.as_enum()) +RackAirflowEnum = strawberry.enum(RackAirflowChoices.as_enum()) +RackDimensionUnitEnum = strawberry.enum(RackDimensionUnitChoices.as_enum()) +RackFormFactorEnum = strawberry.enum(RackFormFactorChoices.as_enum()) +RackStatusEnum = strawberry.enum(RackStatusChoices.as_enum()) +RackWidthEnum = strawberry.enum(RackWidthChoices.as_enum()) +SiteStatusEnum = strawberry.enum(SiteStatusChoices.as_enum()) +SubdeviceRoleEnum = strawberry.enum(SubdeviceRoleChoices.as_enum()) +VirtualDeviceContextStatusEnum = strawberry.enum(VirtualDeviceContextStatusChoices.as_enum()) diff --git a/netbox/dcim/graphql/filter_mixins.py b/netbox/dcim/graphql/filter_mixins.py new file mode 100644 index 000000000..47a75d08e --- /dev/null +++ b/netbox/dcim/graphql/filter_mixins.py @@ -0,0 +1,149 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin +from core.graphql.filters import ContentTypeFilter +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin +from .enums import * + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import IntegerLookup + from extras.graphql.filters import ConfigTemplateFilter + from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter + from .filters import * + +__all__ = ( + 'CabledObjectModelFilterMixin', + 'ComponentModelFilterMixin', + 'ComponentTemplateFilterMixin', + 'InterfaceBaseFilterMixin', + 'ModularComponentModelFilterMixin', + 'ModularComponentTemplateFilterMixin', + 'RackBaseFilterMixin', + 'RenderConfigFilterMixin', + 'ScopedFilterMixin', +) + + +@dataclass +class ScopedFilterMixin(BaseFilterMixin): + scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + scope_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class ComponentModelFilterMixin(NetBoxModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentModelFilterMixin(ComponentModelFilterMixin): + module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + module_id: ID | None = strawberry_django.filter_field() + inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class CabledObjectModelFilterMixin(BaseFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: CableEndEnum | None = strawberry_django.filter_field() + mark_connected: FilterLookup[bool] | None = strawberry_django.filter_field() + + +@dataclass +class ComponentTemplateFilterMixin(ChangeLogFilterMixin): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + + +@dataclass +class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin): + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class RenderConfigFilterMixin(BaseFilterMixin): + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class InterfaceBaseFilterMixin(BaseFilterMixin): + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mode: InterfaceModeEnum | None = strawberry_django.filter_field() + parent: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + untagged_vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tagged_vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_translation_policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None \ + = strawberry_django.filter_field() + primary_mac_address: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_mac_address_id: ID | None = strawberry_django.filter_field() + + +@dataclass +class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin): + width: Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + u_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + starting_unit: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + desc_units: FilterLookup[bool] | None = strawberry_django.filter_field() + outer_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + outer_unit: Annotated['RackDimensionUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mounting_depth: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 94f2c6d38..5dfc0d73c 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -1,7 +1,46 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from dcim import filtersets, models -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import ChangeLogFilterMixin +from dcim import models +from extras.graphql.filter_mixins import ConfigContextFilterMixin +from netbox.graphql.filter_mixins import ( + PrimaryModelFilterMixin, + OrganizationalModelFilterMixin, + NestedGroupModelFilterMixin, + ImageAttachmentFilterMixin, + WeightFilterMixin, +) +from tenancy.graphql.filter_mixins import TenancyFilterMixin, ContactFilterMixin +from .filter_mixins import ( + CabledObjectModelFilterMixin, + ComponentModelFilterMixin, + ComponentTemplateFilterMixin, + InterfaceBaseFilterMixin, + ModularComponentModelFilterMixin, + ModularComponentTemplateFilterMixin, + RackBaseFilterMixin, + RenderConfigFilterMixin, +) + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from extras.graphql.filters import ConfigTemplateFilter, ImageAttachmentFilter + from ipam.graphql.filters import ( + ASNFilter, FHRPGroupAssignmentFilter, IPAddressFilter, PrefixFilter, VLANGroupFilter, VRFFilter, + ) + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import FloatLookup, IntegerArrayLookup, IntegerLookup, TreeNodeFilter + from users.graphql.filters import UserFilter + from virtualization.graphql.filters import ClusterFilter + from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter + from wireless.graphql.enums import WirelessChannelEnum, WirelessRoleEnum + from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter + from .enums import * __all__ = ( 'CableFilter', @@ -13,7 +52,6 @@ __all__ = ( 'DeviceFilter', 'DeviceBayFilter', 'DeviceBayTemplateFilter', - 'InventoryItemTemplateFilter', 'DeviceRoleFilter', 'DeviceTypeFilter', 'FrontPortFilter', @@ -22,6 +60,7 @@ __all__ = ( 'InterfaceTemplateFilter', 'InventoryItemFilter', 'InventoryItemRoleFilter', + 'InventoryItemTemplateFilter', 'LocationFilter', 'MACAddressFilter', 'ManufacturerFilter', @@ -29,6 +68,7 @@ __all__ = ( 'ModuleBayFilter', 'ModuleBayTemplateFilter', 'ModuleTypeFilter', + 'ModuleTypeProfileFilter', 'PlatformFilter', 'PowerFeedFilter', 'PowerOutletFilter', @@ -51,258 +91,768 @@ __all__ = ( @strawberry_django.filter(models.Cable, lookups=True) -@autotype_decorator(filtersets.CableFilterSet) -class CableFilter(BaseFilterMixin): - pass +class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin): + type: Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['LinkStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + length: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + length_unit: Annotated['CableLengthUnitEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + terminations: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.CableTermination, lookups=True) -@autotype_decorator(filtersets.CableTerminationFilterSet) -class CableTerminationFilter(BaseFilterMixin): - pass +class CableTerminationFilter(ChangeLogFilterMixin): + cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + cable_id: ID | None = strawberry_django.filter_field() + cable_end: Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['CableTerminationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + termination_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.ConsolePort, lookups=True) -@autotype_decorator(filtersets.ConsolePortFilterSet) -class ConsolePortFilter(BaseFilterMixin): - pass +class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsolePortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsolePortTemplateFilterSet) -class ConsolePortTemplateFilter(BaseFilterMixin): - pass +class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPort, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortFilterSet) -class ConsoleServerPortFilter(BaseFilterMixin): - pass +class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + speed: Annotated['ConsolePortSpeedEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) -@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet) -class ConsoleServerPortTemplateFilter(BaseFilterMixin): - pass +class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Device, lookups=True) -@autotype_decorator(filtersets.DeviceFilterSet) -class DeviceFilter(BaseFilterMixin): - pass +class DeviceFilter( + ContactFilterMixin, + TenancyFilterMixin, + ImageAttachmentFilterMixin, + RenderConfigFilterMixin, + ConfigContextFilterMixin, + PrimaryModelFilterMixin, +): + device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_type_id: ID | None = strawberry_django.filter_field() + role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + position: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + face: Annotated['DeviceFaceEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + status: Annotated['DeviceStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + oob_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + oob_ip_id: ID | None = strawberry_django.filter_field() + cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + virtual_chassis: Annotated['VirtualChassisFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_chassis_id: ID | None = strawberry_django.filter_field() + vc_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + vc_priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBay, lookups=True) -@autotype_decorator(filtersets.DeviceBayFilterSet) -class DeviceBayFilter(BaseFilterMixin): - pass +class DeviceBayFilter(ComponentModelFilterMixin): + installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + installed_device_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceBayTemplate, lookups=True) -@autotype_decorator(filtersets.DeviceBayTemplateFilterSet) -class DeviceBayTemplateFilter(BaseFilterMixin): +class DeviceBayTemplateFilter(ComponentTemplateFilterMixin): pass @strawberry_django.filter(models.InventoryItemTemplate, lookups=True) -@autotype_decorator(filtersets.InventoryItemTemplateFilterSet) -class InventoryItemTemplateFilter(BaseFilterMixin): - pass +class InventoryItemTemplateFilter(ComponentTemplateFilterMixin): + parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceRole, lookups=True) -@autotype_decorator(filtersets.DeviceRoleFilterSet) -class DeviceRoleFilter(BaseFilterMixin): - pass +class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + vm_role: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.DeviceType, lookups=True) -@autotype_decorator(filtersets.DeviceTypeFilterSet) -class DeviceTypeFilter(BaseFilterMixin): - pass +class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + default_platform_id: ID | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + exclude_from_utilization: FilterLookup[bool] | None = strawberry_django.filter_field() + is_full_depth: FilterLookup[bool] | None = strawberry_django.filter_field() + subdevice_role: Annotated['SubdeviceRoleEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + airflow: Annotated['DeviceAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + front_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + power_outlet_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + interface_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + front_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + rear_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field() + inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.FrontPort, lookups=True) -@autotype_decorator(filtersets.FrontPortFilterSet) -class FrontPortFilter(BaseFilterMixin): - pass +class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.FrontPortTemplate, lookups=True) -@autotype_decorator(filtersets.FrontPortTemplateFilterSet) -class FrontPortTemplateFilter(BaseFilterMixin): - pass +class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rear_port_id: ID | None = strawberry_django.filter_field() + rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.MACAddress, lookups=True) -@autotype_decorator(filtersets.MACAddressFilterSet) -class MACAddressFilter(BaseFilterMixin): - pass +class MACAddressFilter(PrimaryModelFilterMixin): + mac_address: FilterLookup[str] | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.Interface, lookups=True) -@autotype_decorator(filtersets.InterfaceFilterSet) -class InterfaceFilter(BaseFilterMixin): - pass +class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin): + vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + lag: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + lag_id: ID | None = strawberry_django.filter_field() + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + speed: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + duplex: Annotated['InterfaceDuplexEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wwn: FilterLookup[str] | None = strawberry_django.filter_field() + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel: Annotated['WirelessChannelEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_frequency: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + rf_channel_width: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tx_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + wireless_link: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + wireless_link_id: ID | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + vrf_id: ID | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + fhrp_group_assignments: Annotated['FHRPGroupAssignmentFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_terminations: Annotated['TunnelTerminationFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InterfaceTemplate, lookups=True) -@autotype_decorator(filtersets.InterfaceTemplateFilterSet) -class InterfaceTemplateFilter(BaseFilterMixin): - pass +class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field() + bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + bridge_id: ID | None = strawberry_django.filter_field() + poe_mode: Annotated['InterfacePoEModeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + poe_type: Annotated['InterfacePoETypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rf_role: Annotated['WirelessRoleEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.InventoryItem, lookups=True) -@autotype_decorator(filtersets.InventoryItemFilterSet) -class InventoryItemFilter(BaseFilterMixin): - pass +class InventoryItemFilter(ComponentModelFilterMixin): + parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + component_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + component_id: ID | None = strawberry_django.filter_field() + status: Annotated['InventoryItemStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + role: Annotated['InventoryItemRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + part_id: FilterLookup[str] | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + discovered: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.InventoryItemRole, lookups=True) -@autotype_decorator(filtersets.InventoryItemRoleFilterSet) -class InventoryItemRoleFilter(BaseFilterMixin): - pass +class InventoryItemRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Location, lookups=True) -@autotype_decorator(filtersets.LocationFilterSet) -class LocationFilter(BaseFilterMixin): - pass +class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin): + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + status: Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Manufacturer, lookups=True) -@autotype_decorator(filtersets.ManufacturerFilterSet) -class ManufacturerFilter(BaseFilterMixin): +class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.Module, lookups=True) -@autotype_decorator(filtersets.ModuleFilterSet) -class ModuleFilter(BaseFilterMixin): - pass +class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_bay_id: ID | None = strawberry_django.filter_field() + module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + module_type_id: ID | None = strawberry_django.filter_field() + status: Annotated['ModuleStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleBay, lookups=True) -@autotype_decorator(filtersets.ModuleBayFilterSet) -class ModuleBayFilter(BaseFilterMixin): - pass +class ModuleBayFilter(ModularComponentModelFilterMixin): + parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry_django.filter_field() + position: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleBayTemplate, lookups=True) -@autotype_decorator(filtersets.ModuleBayTemplateFilterSet) -class ModuleBayTemplateFilter(BaseFilterMixin): - pass +class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): + position: FilterLookup[str] | None = strawberry_django.filter_field() + + +@strawberry_django.filter(models.ModuleTypeProfile, lookups=True) +class ModuleTypeProfileFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ModuleType, lookups=True) -@autotype_decorator(filtersets.ModuleTypeFilterSet) -class ModuleTypeFilter(BaseFilterMixin): - pass +class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + part_number: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Platform, lookups=True) -@autotype_decorator(filtersets.PlatformFilterSet) -class PlatformFilter(BaseFilterMixin): - pass +class PlatformFilter(OrganizationalModelFilterMixin): + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + config_template_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerFeed, lookups=True) -@autotype_decorator(filtersets.PowerFeedFilterSet) -class PowerFeedFilter(BaseFilterMixin): - pass +class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_panel_id: ID | None = strawberry_django.filter_field() + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['PowerFeedStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + type: Annotated['PowerFeedTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + supply: Annotated['PowerFeedSupplyEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + phase: Annotated['PowerFeedPhaseEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + voltage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + amperage: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + max_utilization: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + available_power: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerOutlet, lookups=True) -@autotype_decorator(filtersets.PowerOutletFilterSet) -class PowerOutletFilter(BaseFilterMixin): - pass +class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerOutletTemplate, lookups=True) -@autotype_decorator(filtersets.PowerOutletTemplateFilterSet) -class PowerOutletTemplateFilter(BaseFilterMixin): - pass +class PowerOutletTemplateFilter(ModularComponentModelFilterMixin): + type: Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + power_port: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_port_id: ID | None = strawberry_django.filter_field() + feed_leg: Annotated['PowerOutletFeedLegEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPanel, lookups=True) -@autotype_decorator(filtersets.PowerPanelFilterSet) -class PowerPanelFilter(BaseFilterMixin): - pass +class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin): + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.PowerPort, lookups=True) -@autotype_decorator(filtersets.PowerPortFilterSet) -class PowerPortFilter(BaseFilterMixin): - pass +class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.PowerPortTemplate, lookups=True) -@autotype_decorator(filtersets.PowerPortTemplateFilterSet) -class PowerPortTemplateFilter(BaseFilterMixin): - pass +class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + maximum_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + allocated_draw: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackType, lookups=True) -@autotype_decorator(filtersets.RackTypeFilterSet) -class RackTypeFilter(BaseFilterMixin): - pass +class RackTypeFilter(RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + manufacturer_id: ID | None = strawberry_django.filter_field() + model: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Rack, lookups=True) -@autotype_decorator(filtersets.RackFilterSet) -class RackFilter(BaseFilterMixin): - pass +class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin): + form_factor: Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + rack_type: Annotated['RackTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rack_type_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + facility_id: FilterLookup[str] | None = strawberry_django.filter_field() + site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + site_id: ID | None = strawberry_django.filter_field() + location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + location_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + status: Annotated['RackStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + role: Annotated['RackRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + role_id: ID | None = strawberry_django.filter_field() + serial: FilterLookup[str] | None = strawberry_django.filter_field() + asset_tag: FilterLookup[str] | None = strawberry_django.filter_field() + airflow: Annotated['RackAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RackReservation, lookups=True) -@autotype_decorator(filtersets.RackReservationFilterSet) -class RackReservationFilter(BaseFilterMixin): - pass +class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + rack_id: ID | None = strawberry_django.filter_field() + units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + user_id: ID | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RackRole, lookups=True) -@autotype_decorator(filtersets.RackRoleFilterSet) -class RackRoleFilter(BaseFilterMixin): - pass +class RackRoleFilter(OrganizationalModelFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.RearPort, lookups=True) -@autotype_decorator(filtersets.RearPortFilterSet) -class RearPortFilter(BaseFilterMixin): - pass +class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.RearPortTemplate, lookups=True) -@autotype_decorator(filtersets.RearPortTemplateFilterSet) -class RearPortTemplateFilter(BaseFilterMixin): - pass +class RearPortTemplateFilter(ModularComponentTemplateFilterMixin): + type: Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + positions: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Region, lookups=True) -@autotype_decorator(filtersets.RegionFilterSet) -class RegionFilter(BaseFilterMixin): - pass +class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Site, lookups=True) -@autotype_decorator(filtersets.SiteFilterSet) -class SiteFilter(BaseFilterMixin): - pass +class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = strawberry_django.filter_field() + region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + region_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + facility: FilterLookup[str] | None = strawberry_django.filter_field() + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + time_zone: FilterLookup[str] | None = strawberry_django.filter_field() + physical_address: FilterLookup[str] | None = strawberry_django.filter_field() + shipping_address: FilterLookup[str] | None = strawberry_django.filter_field() + latitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.SiteGroup, lookups=True) -@autotype_decorator(filtersets.SiteGroupFilterSet) -class SiteGroupFilter(BaseFilterMixin): - pass +class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin): + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualChassis, lookups=True) -@autotype_decorator(filtersets.VirtualChassisFilterSet) -class VirtualChassisFilter(BaseFilterMixin): - pass +class VirtualChassisFilter(PrimaryModelFilterMixin): + master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + master_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + domain: FilterLookup[str] | None = strawberry_django.filter_field() + member_count: FilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter(models.VirtualDeviceContext, lookups=True) -@autotype_decorator(filtersets.VirtualDeviceContextFilterSet) -class VirtualDeviceContextFilter(BaseFilterMixin): - pass +class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['VirtualDeviceContextStatusEnum', strawberry.lazy('dcim.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip4_id: ID | None = strawberry_django.filter_field() + primary_ip6: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + primary_ip6_id: ID | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 011a2b58b..1b0661bc2 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -77,6 +77,9 @@ class DCIMQuery: module_bay_template: ModuleBayTemplateType = strawberry_django.field() module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + module_type_profile: ModuleTypeProfileType = strawberry_django.field() + module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field() + module_type: ModuleTypeType = strawberry_django.field() module_type_list: List[ModuleTypeType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8d992176a..fb8c136ad 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List, Union +from typing import Annotated, List, TYPE_CHECKING, Union import strawberry import strawberry_django @@ -6,7 +6,11 @@ import strawberry_django from core.graphql.mixins import ChangelogMixin from dcim import models from extras.graphql.mixins import ( - ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ConfigContextMixin, + ContactsMixin, + CustomFieldsMixin, + ImageAttachmentsMixin, + TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt @@ -14,6 +18,23 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa from .filters import * from .mixins import CabledObjectMixin, PathEndpointMixin +if TYPE_CHECKING: + from circuits.graphql.types import CircuitTerminationType + from extras.graphql.types import ConfigTemplateType + from ipam.graphql.types import ( + ASNType, + IPAddressType, + PrefixType, + ServiceType, + VLANTranslationPolicyType, + VLANType, + VRFType, + ) + from tenancy.graphql.types import TenantType + from users.graphql.types import UserType + from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType + from wireless.graphql.types import WirelessLANType, WirelessLinkType + __all__ = ( 'CableType', 'ComponentType', @@ -40,6 +61,7 @@ __all__ = ( 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', + 'ModuleTypeProfileType', 'ModuleTypeType', 'PlatformType', 'PowerFeedType', @@ -111,8 +133,9 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, - exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'), - filters=CableTerminationFilter + exclude=['termination_type', 'termination_id', '_device', '_rack', '_location', '_site'], + filters=CableTerminationFilter, + pagination=True ) class CableTerminationType(NetBoxObjectType): cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None @@ -132,7 +155,8 @@ class CableTerminationType(NetBoxObjectType): @strawberry_django.type( models.Cable, fields='__all__', - filters=CableFilter + filters=CableFilter, + pagination=True ) class CableType(NetBoxObjectType): color: str @@ -167,8 +191,9 @@ class CableType(NetBoxObjectType): @strawberry_django.type( models.ConsolePort, - exclude=('_path',), - filters=ConsolePortFilter + exclude=['_path'], + filters=ConsolePortFilter, + pagination=True ) class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -177,7 +202,8 @@ class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.ConsolePortTemplate, fields='__all__', - filters=ConsolePortTemplateFilter + filters=ConsolePortTemplateFilter, + pagination=True ) class ConsolePortTemplateType(ModularComponentTemplateType): pass @@ -185,8 +211,9 @@ class ConsolePortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.ConsoleServerPort, - exclude=('_path',), - filters=ConsoleServerPortFilter + exclude=['_path'], + filters=ConsoleServerPortFilter, + pagination=True ) class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): pass @@ -195,7 +222,8 @@ class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpoin @strawberry_django.type( models.ConsoleServerPortTemplate, fields='__all__', - filters=ConsoleServerPortTemplateFilter + filters=ConsoleServerPortTemplateFilter, + pagination=True ) class ConsoleServerPortTemplateType(ModularComponentTemplateType): pass @@ -204,7 +232,8 @@ class ConsoleServerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Device, fields='__all__', - filters=DeviceFilter + filters=DeviceFilter, + pagination=True ) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): console_port_count: BigInt @@ -259,7 +288,8 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo @strawberry_django.type( models.DeviceBay, fields='__all__', - filters=DeviceBayFilter + filters=DeviceBayFilter, + pagination=True ) class DeviceBayType(ComponentType): installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None @@ -268,7 +298,8 @@ class DeviceBayType(ComponentType): @strawberry_django.type( models.DeviceBayTemplate, fields='__all__', - filters=DeviceBayTemplateFilter + filters=DeviceBayTemplateFilter, + pagination=True ) class DeviceBayTemplateType(ComponentTemplateType): pass @@ -276,8 +307,9 @@ class DeviceBayTemplateType(ComponentTemplateType): @strawberry_django.type( models.InventoryItemTemplate, - exclude=('component_type', 'component_id', 'parent'), - filters=InventoryItemTemplateFilter + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemTemplateFilter, + pagination=True ) class InventoryItemTemplateType(ComponentTemplateType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None @@ -303,9 +335,12 @@ class InventoryItemTemplateType(ComponentTemplateType): @strawberry_django.type( models.DeviceRole, fields='__all__', - filters=DeviceRoleFilter + filters=DeviceRoleFilter, + pagination=True ) class DeviceRoleType(OrganizationalObjectType): + parent: Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')] | None + children: List[Annotated['DeviceRoleType', strawberry.lazy('dcim.graphql.types')]] color: str config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None @@ -316,7 +351,8 @@ class DeviceRoleType(OrganizationalObjectType): @strawberry_django.type( models.DeviceType, fields='__all__', - filters=DeviceTypeFilter + filters=DeviceTypeFilter, + pagination=True ) class DeviceTypeType(NetBoxObjectType): console_port_template_count: BigInt @@ -350,7 +386,8 @@ class DeviceTypeType(NetBoxObjectType): @strawberry_django.type( models.FrontPort, fields='__all__', - filters=FrontPortFilter + filters=FrontPortFilter, + pagination=True ) class FrontPortType(ModularComponentType, CabledObjectMixin): color: str @@ -360,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.FrontPortTemplate, fields='__all__', - filters=FrontPortTemplateFilter + filters=FrontPortTemplateFilter, + pagination=True ) class FrontPortTemplateType(ModularComponentTemplateType): color: str @@ -369,8 +407,9 @@ class FrontPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.MACAddress, - exclude=('assigned_object_type', 'assigned_object_id'), - filters=MACAddressFilter + exclude=['assigned_object_type', 'assigned_object_id'], + filters=MACAddressFilter, + pagination=True ) class MACAddressType(NetBoxObjectType): mac_address: str @@ -385,8 +424,9 @@ class MACAddressType(NetBoxObjectType): @strawberry_django.type( models.Interface, - exclude=('_path',), - filters=InterfaceFilter + exclude=['_path'], + filters=InterfaceFilter, + pagination=True ) class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): _name: str @@ -413,7 +453,8 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P @strawberry_django.type( models.InterfaceTemplate, fields='__all__', - filters=InterfaceTemplateFilter + filters=InterfaceTemplateFilter, + pagination=True ) class InterfaceTemplateType(ModularComponentTemplateType): _name: str @@ -424,8 +465,9 @@ class InterfaceTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.InventoryItem, - exclude=('component_type', 'component_id', 'parent'), - filters=InventoryItemFilter + exclude=['component_type', 'component_id', 'parent'], + filters=InventoryItemFilter, + pagination=True ) class InventoryItemType(ComponentType): role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None @@ -451,7 +493,8 @@ class InventoryItemType(ComponentType): @strawberry_django.type( models.InventoryItemRole, fields='__all__', - filters=InventoryItemRoleFilter + filters=InventoryItemRoleFilter, + pagination=True ) class InventoryItemRoleType(OrganizationalObjectType): color: str @@ -463,8 +506,9 @@ class InventoryItemRoleType(OrganizationalObjectType): @strawberry_django.type( models.Location, # fields='__all__', - exclude=('parent',), # bug - temp - filters=LocationFilter + exclude=['parent'], # bug - temp + filters=LocationFilter, + pagination=True ) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -491,7 +535,8 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.type( models.Manufacturer, fields='__all__', - filters=ManufacturerFilter + filters=ManufacturerFilter, + pagination=True ) class ManufacturerType(OrganizationalObjectType, ContactsMixin): @@ -505,7 +550,8 @@ class ManufacturerType(OrganizationalObjectType, ContactsMixin): @strawberry_django.type( models.Module, fields='__all__', - filters=ModuleFilter + filters=ModuleFilter, + pagination=True ) class ModuleType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] @@ -524,8 +570,9 @@ class ModuleType(NetBoxObjectType): @strawberry_django.type( models.ModuleBay, # fields='__all__', - exclude=('parent',), - filters=ModuleBayFilter + exclude=['parent'], + filters=ModuleBayFilter, + pagination=True ) class ModuleBayType(ModularComponentType): @@ -540,18 +587,31 @@ class ModuleBayType(ModularComponentType): @strawberry_django.type( models.ModuleBayTemplate, fields='__all__', - filters=ModuleBayTemplateFilter + filters=ModuleBayTemplateFilter, + pagination=True ) class ModuleBayTemplateType(ModularComponentTemplateType): pass +@strawberry_django.type( + models.ModuleTypeProfile, + fields='__all__', + filters=ModuleTypeProfileFilter, + pagination=True +) +class ModuleTypeProfileType(NetBoxObjectType): + module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.type( models.ModuleType, fields='__all__', - filters=ModuleTypeFilter + filters=ModuleTypeFilter, + pagination=True ) class ModuleTypeType(NetBoxObjectType): + profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -567,7 +627,8 @@ class ModuleTypeType(NetBoxObjectType): @strawberry_django.type( models.Platform, fields='__all__', - filters=PlatformFilter + filters=PlatformFilter, + pagination=True ) class PlatformType(OrganizationalObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None @@ -579,8 +640,9 @@ class PlatformType(OrganizationalObjectType): @strawberry_django.type( models.PowerFeed, - exclude=('_path',), - filters=PowerFeedFilter + exclude=['_path'], + filters=PowerFeedFilter, + pagination=True ) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')] @@ -590,8 +652,9 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerOutlet, - exclude=('_path',), - filters=PowerOutletFilter + exclude=['_path'], + filters=PowerOutletFilter, + pagination=True ) class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None @@ -601,7 +664,8 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin @strawberry_django.type( models.PowerOutletTemplate, fields='__all__', - filters=PowerOutletTemplateFilter + filters=PowerOutletTemplateFilter, + pagination=True ) class PowerOutletTemplateType(ModularComponentTemplateType): power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None @@ -610,7 +674,8 @@ class PowerOutletTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.PowerPanel, fields='__all__', - filters=PowerPanelFilter + filters=PowerPanelFilter, + pagination=True ) class PowerPanelType(NetBoxObjectType, ContactsMixin): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -621,8 +686,9 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin): @strawberry_django.type( models.PowerPort, - exclude=('_path',), - filters=PowerPortFilter + exclude=['_path'], + filters=PowerPortFilter, + pagination=True ) class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @@ -632,7 +698,8 @@ class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): @strawberry_django.type( models.PowerPortTemplate, fields='__all__', - filters=PowerPortTemplateFilter + filters=PowerPortTemplateFilter, + pagination=True ) class PowerPortTemplateType(ModularComponentTemplateType): poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] @@ -641,7 +708,8 @@ class PowerPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.RackType, fields='__all__', - filters=RackTypeFilter + filters=RackTypeFilter, + pagination=True ) class RackTypeType(NetBoxObjectType): manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] @@ -650,7 +718,8 @@ class RackTypeType(NetBoxObjectType): @strawberry_django.type( models.Rack, fields='__all__', - filters=RackFilter + filters=RackFilter, + pagination=True ) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] @@ -668,7 +737,8 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.RackReservation, fields='__all__', - filters=RackReservationFilter + filters=RackReservationFilter, + pagination=True ) class RackReservationType(NetBoxObjectType): units: List[int] @@ -680,7 +750,8 @@ class RackReservationType(NetBoxObjectType): @strawberry_django.type( models.RackRole, fields='__all__', - filters=RackRoleFilter + filters=RackRoleFilter, + pagination=True ) class RackRoleType(OrganizationalObjectType): color: str @@ -691,7 +762,8 @@ class RackRoleType(OrganizationalObjectType): @strawberry_django.type( models.RearPort, fields='__all__', - filters=RearPortFilter + filters=RearPortFilter, + pagination=True ) class RearPortType(ModularComponentType, CabledObjectMixin): color: str @@ -702,7 +774,8 @@ class RearPortType(ModularComponentType, CabledObjectMixin): @strawberry_django.type( models.RearPortTemplate, fields='__all__', - filters=RearPortTemplateFilter + filters=RearPortTemplateFilter, + pagination=True ) class RearPortTemplateType(ModularComponentTemplateType): color: str @@ -712,9 +785,9 @@ class RearPortTemplateType(ModularComponentTemplateType): @strawberry_django.type( models.Region, - exclude=('parent',), - # fields='__all__', - filters=RegionFilter + exclude=['parent'], + filters=RegionFilter, + pagination=True ) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -739,7 +812,8 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.Site, fields='__all__', - filters=SiteFilter + filters=SiteFilter, + pagination=True ) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): time_zone: str | None @@ -772,9 +846,9 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.type( models.SiteGroup, - # fields='__all__', - exclude=('parent',), # bug - temp - filters=SiteGroupFilter + exclude=['parent'], # bug - temp + filters=SiteGroupFilter, + pagination=True ) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @@ -799,7 +873,8 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.type( models.VirtualChassis, fields='__all__', - filters=VirtualChassisFilter + filters=VirtualChassisFilter, + pagination=True ) class VirtualChassisType(NetBoxObjectType): member_count: BigInt @@ -811,7 +886,8 @@ class VirtualChassisType(NetBoxObjectType): @strawberry_django.type( models.VirtualDeviceContext, fields='__all__', - filters=VirtualDeviceContextFilter + filters=VirtualDeviceContextFilter, + pagination=True ) class VirtualDeviceContextType(NetBoxObjectType): device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index 2e830560f..ae1966e58 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -7,11 +7,11 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0001_initial'), + ('dcim', '0001_squashed'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 0248d9ba1..490ab8e8b 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -5,12 +5,12 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('virtualization', '0001_virtualization'), + ('dcim', '0002_squashed'), + ('virtualization', '0001_squashed_0022'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0001_initial'), - ('tenancy', '0001_initial'), - ('extras', '0002_custom_fields'), + ('ipam', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), + ('extras', '0002_squashed_0059'), ] replaces = [ @@ -505,28 +505,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_a_type', field=models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', @@ -536,28 +514,6 @@ class Migration(migrations.Migration): model_name='cable', name='termination_b_type', field=models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/dcim/migrations/0131_squashed_0159.py b/netbox/dcim/migrations/0131_squashed_0159.py index 3866e8cc8..1c1f2ff38 100644 --- a/netbox/dcim/migrations/0131_squashed_0159.py +++ b/netbox/dcim/migrations/0131_squashed_0159.py @@ -43,12 +43,12 @@ class Migration(migrations.Migration): ] dependencies = [ - ('tenancy', '0012_standardize_models'), + ('tenancy', '0001_squashed_0012'), ('extras', '0002_squashed_0059'), - ('dcim', '0130_sitegroup'), + ('dcim', '0003_squashed_0130'), ('contenttypes', '0002_remove_content_type_name'), - ('ipam', '0053_asn_model'), - ('wireless', '0001_wireless'), + ('ipam', '0047_squashed_0053'), + ('wireless', '0001_squashed_0008'), ] operations = [ @@ -866,21 +866,6 @@ class Migration(migrations.Migration): name='component_type', field=models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', @@ -1238,21 +1223,6 @@ class Migration(migrations.Migration): 'component_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleporttemplate', - 'consoleserverporttemplate', - 'frontporttemplate', - 'interfacetemplate', - 'poweroutlettemplate', - 'powerporttemplate', - 'rearporttemplate', - ), - ), - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', @@ -1478,28 +1448,6 @@ class Migration(migrations.Migration): ( 'termination_type', models.ForeignKey( - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), - models.Q( - ('app_label', 'dcim'), - ( - 'model__in', - ( - 'consoleport', - 'consoleserverport', - 'frontport', - 'interface', - 'powerfeed', - 'poweroutlet', - 'powerport', - 'rearport', - ), - ), - ), - _connector='OR', - ) - ), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype', diff --git a/netbox/dcim/migrations/0160_squashed_0166.py b/netbox/dcim/migrations/0160_squashed_0166.py index 0deb58bab..5cff94f4a 100644 --- a/netbox/dcim/migrations/0160_squashed_0166.py +++ b/netbox/dcim/migrations/0160_squashed_0166.py @@ -18,9 +18,9 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0047_squashed_0053'), - ('tenancy', '0009_standardize_description_comments'), - ('circuits', '0037_new_cabling_models'), - ('dcim', '0159_populate_cable_paths'), + ('tenancy', '0001_squashed_0012'), + ('circuits', '0003_squashed_0037'), + ('dcim', '0131_squashed_0159'), ] operations = [ diff --git a/netbox/dcim/migrations/0167_squashed_0182.py b/netbox/dcim/migrations/0167_squashed_0182.py index d0ad5379f..ba077ff4e 100644 --- a/netbox/dcim/migrations/0167_squashed_0182.py +++ b/netbox/dcim/migrations/0167_squashed_0182.py @@ -27,10 +27,10 @@ class Migration(migrations.Migration): ] dependencies = [ - ('extras', '0086_configtemplate'), - ('tenancy', '0010_tenant_relax_uniqueness'), + ('extras', '0060_squashed_0086'), + ('tenancy', '0002_squashed_0011'), ('ipam', '0047_squashed_0053'), - ('dcim', '0166_virtualdevicecontext'), + ('dcim', '0160_squashed_0166'), ] operations = [ diff --git a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py index f9f2c20b4..2e3edb08a 100644 --- a/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py +++ b/netbox/dcim/migrations/0183_devicetype_exclude_from_utilization.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0182_zero_length_cable_fix'), + ('dcim', '0167_squashed_0182'), ] operations = [ diff --git a/netbox/dcim/migrations/0199_macaddress.py b/netbox/dcim/migrations/0199_macaddress.py index ae18d5f63..c668858b4 100644 --- a/netbox/dcim/migrations/0199_macaddress.py +++ b/netbox/dcim/migrations/0199_macaddress.py @@ -31,13 +31,6 @@ class Migration(migrations.Migration): 'assigned_object_type', models.ForeignKey( blank=True, - limit_choices_to=models.Q( - models.Q( - models.Q(('app_label', 'dcim'), ('model', 'interface')), - models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), - _connector='OR', - ) - ), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', diff --git a/netbox/dcim/migrations/0201_add_power_outlet_status.py b/netbox/dcim/migrations/0201_add_power_outlet_status.py new file mode 100644 index 000000000..21fd32186 --- /dev/null +++ b/netbox/dcim/migrations/0201_add_power_outlet_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0200_populate_mac_addresses'), + ] + + operations = [ + migrations.AddField( + model_name='poweroutlet', + name='status', + field=models.CharField(default='enabled', max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py new file mode 100644 index 000000000..ffdc5ba8a --- /dev/null +++ b/netbox/dcim/migrations/0202_location_comments_region_comments_sitegroup_comments.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0201_add_power_outlet_status'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='region', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='sitegroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/dcim/migrations/0203_add_rack_outer_height.py b/netbox/dcim/migrations/0203_add_rack_outer_height.py new file mode 100644 index 000000000..2d2fef265 --- /dev/null +++ b/netbox/dcim/migrations/0203_add_rack_outer_height.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2b1 on 2025-03-18 15:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0202_location_comments_region_comments_sitegroup_comments'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='racktype', + name='outer_height', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0203_device_role_nested.py b/netbox/dcim/migrations/0203_device_role_nested.py new file mode 100644 index 000000000..c9dd791b3 --- /dev/null +++ b/netbox/dcim/migrations/0203_device_role_nested.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.7 on 2025-03-25 18:06 + +import django.db.models.manager +import mptt.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0203_add_rack_outer_height'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='devicerole', + name='parent', + field=mptt.fields.TreeForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='dcim.devicerole', + ), + ), + migrations.AddField( + model_name='devicerole', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='devicerole', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='devicerole', + name='slug', + field=models.SlugField(max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0204_device_role_rebuild.py b/netbox/dcim/migrations/0204_device_role_rebuild.py new file mode 100644 index 000000000..69837c522 --- /dev/null +++ b/netbox/dcim/migrations/0204_device_role_rebuild.py @@ -0,0 +1,22 @@ +from django.db import migrations +import mptt +import mptt.managers + + +def rebuild_mptt(apps, schema_editor): + manager = mptt.managers.TreeManager() + DeviceRole = apps.get_model('dcim', 'DeviceRole') + manager.model = DeviceRole + mptt.register(DeviceRole) + manager.contribute_to_class(DeviceRole, 'objects') + manager.rebuild() + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0203_device_role_nested'), + ] + + operations = [ + migrations.RunPython(code=rebuild_mptt, reverse_code=migrations.RunPython.noop), + ] diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py new file mode 100644 index 000000000..3e9a4d6d8 --- /dev/null +++ b/netbox/dcim/migrations/0205_moduletypeprofile.py @@ -0,0 +1,57 @@ +import django.db.models.deletion +import taggit.managers +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0204_device_role_rebuild'), + ('extras', '0125_exporttemplate_file_name'), + ] + + operations = [ + migrations.CreateModel( + name='ModuleTypeProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ( + 'custom_field_data', + models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('schema', models.JSONField(blank=True, null=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'module type profile', + 'verbose_name_plural': 'module type profiles', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='moduletype', + name='attribute_data', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='moduletype', + name='profile', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='module_types', + to='dcim.moduletypeprofile', + ), + ), + migrations.AlterModelOptions( + name='moduletype', + options={'ordering': ('profile', 'manufacturer', 'model')}, + ), + ] diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py new file mode 100644 index 000000000..e3ca7d27a --- /dev/null +++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +from django.db import migrations + +DATA_FILES_PATH = Path(__file__).parent / 'initial_data' / 'module_type_profiles' + + +def load_initial_data(apps, schema_editor): + """ + Load initial ModuleTypeProfile objects from file. + """ + ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile') + initial_profiles = ( + 'cpu', + 'fan', + 'gpu', + 'hard_disk', + 'memory', + 'power_supply' + ) + + for name in initial_profiles: + file_path = DATA_FILES_PATH / f'{name}.json' + with file_path.open('r') as f: + data = json.load(f) + try: + ModuleTypeProfile.objects.create(**data) + except Exception as e: + print(f"Error loading data from {file_path}") + raise e + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0205_moduletypeprofile'), + ] + + operations = [ + migrations.RunPython(load_initial_data), + ] diff --git a/netbox/dcim/migrations/0207_remove_redundant_indexes.py b/netbox/dcim/migrations/0207_remove_redundant_indexes.py new file mode 100644 index 000000000..b63e6423f --- /dev/null +++ b/netbox/dcim/migrations/0207_remove_redundant_indexes.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0206_load_module_type_profiles'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='cabletermination', + name='dcim_cablet_termina_884752_idx', + ), + ] diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json new file mode 100644 index 000000000..255886c5e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json @@ -0,0 +1,20 @@ +{ + "name": "CPU", + "schema": { + "properties": { + "architecture": { + "type": "string", + "title": "Architecture" + }, + "speed": { + "type": "number", + "title": "Speed", + "description": "Clock speed in GHz" + }, + "cores": { + "type": "integer", + "description": "Number of cores present" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json new file mode 100644 index 000000000..e6a2a384e --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json @@ -0,0 +1,12 @@ +{ + "name": "Fan", + "schema": { + "properties": { + "rpm": { + "type": "integer", + "title": "RPM", + "description": "Fan speed (RPM)" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json new file mode 100644 index 000000000..1725a4ab7 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json @@ -0,0 +1,28 @@ +{ + "name": "GPU", + "schema": { + "properties": { + "interface": { + "type": "string", + "enum": [ + "PCIe 4.0", + "PCIe 4.0 x8", + "PCIe 4.0 x16", + "PCIe 5.0 x16" + ] + }, + "gpu" : { + "type": "string", + "title": "GPU" + }, + "memory": { + "type": "integer", + "title": "Memory (GB)", + "description": "Total memory capacity (in GB)" + } + }, + "required": [ + "memory" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json new file mode 100644 index 000000000..8d55cfde6 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json @@ -0,0 +1,29 @@ +{ + "name": "Hard disk", + "schema": { + "properties": { + "type": { + "type": "string", + "title": "Disk type", + "enum": [ + "HD", + "SSD", + "NVME" + ], + "default": "SSD" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw disk capacity" + }, + "speed": { + "type": "integer", + "title": "Speed (RPM)" + } + }, + "required": [ + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json new file mode 100644 index 000000000..8346bfce9 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json @@ -0,0 +1,36 @@ +{ + "name": "Memory", + "schema": { + "properties": { + "class": { + "type": "string", + "title": "Memory class", + "enum": [ + "DDR3", + "DDR4", + "DDR5" + ], + "default": "DDR5" + }, + "size": { + "type": "integer", + "title": "Size (GB)", + "description": "Raw capacity of the module" + }, + "data_rate": { + "type": "integer", + "title": "Data rate", + "description": "Speed in MT/s" + }, + "ecc": { + "type": "boolean", + "title": "ECC", + "description": "Error-correcting code is enabled" + } + }, + "required": [ + "class", + "size" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json new file mode 100644 index 000000000..ea060a889 --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json @@ -0,0 +1,34 @@ +{ + "name": "Power supply", + "schema": { + "properties": { + "input_current": { + "type": "string", + "title": "Current type", + "enum": [ + "AC", + "DC" + ], + "default": "AC" + }, + "input_voltage": { + "type": "integer", + "title": "Voltage", + "default": 120 + }, + "wattage": { + "type": "integer", + "description": "Available output power (watts)" + }, + "hot_swappable": { + "type": "boolean", + "title": "Hot-swappable", + "default": false + } + }, + "required": [ + "input_current", + "input_voltage" + ] + } +} diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d74f34828..33af25678 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2,6 +2,7 @@ from .cables import * from .device_component_templates import * from .device_components import * from .devices import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7117ea7e0..edb010a22 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -259,7 +259,6 @@ class CableTermination(ChangeLoggedModel): ) termination_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=CABLE_TERMINATION_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -299,9 +298,6 @@ class CableTermination(ChangeLoggedModel): class Meta: ordering = ('cable', 'cable_end', 'pk') - indexes = ( - models.Index(fields=('termination_type', 'termination_id')), - ) constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b4f057711..e0b05b388 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -751,7 +751,6 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ce9e5607f..0ff74529f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -449,6 +449,12 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=PowerOutletStatusChoices, + default=PowerOutletStatusChoices.STATUS_ENABLED + ) type = models.CharField( verbose_name=_('type'), max_length=50, @@ -492,6 +498,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki _("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port) ) + def get_status_color(self): + return PowerOutletStatusChoices.colors.get(self.status) + # # Interfaces @@ -934,6 +943,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")}) # VLAN validation + if not self.mode and self.untagged_vlan: + raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")}) # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: @@ -1263,7 +1274,6 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin): ) component_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MODULAR_COMPONENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2acd98801..f9c72b7c6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,17 +19,19 @@ from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField +from dcim.utils import update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin +from .modules import Module __all__ = ( @@ -38,8 +40,6 @@ __all__ = ( 'DeviceType', 'MACAddress', 'Manufacturer', - 'Module', - 'ModuleType', 'Platform', 'VirtualChassis', 'VirtualDeviceContext', @@ -367,108 +367,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): - """ - A ModuleType represents a hardware element that can be installed within a device and which houses additional - components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a - DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It - cannot, however house device bays or module bays. - """ - manufacturer = models.ForeignKey( - to='dcim.Manufacturer', - on_delete=models.PROTECT, - related_name='module_types' - ) - model = models.CharField( - verbose_name=_('model'), - max_length=100 - ) - part_number = models.CharField( - verbose_name=_('part number'), - max_length=50, - blank=True, - help_text=_('Discrete part number (optional)') - ) - airflow = models.CharField( - verbose_name=_('airflow'), - max_length=50, - choices=ModuleAirflowChoices, - blank=True, - null=True - ) - - clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow') - prerequisite_models = ( - 'dcim.Manufacturer', - ) - - class Meta: - ordering = ('manufacturer', 'model') - constraints = ( - models.UniqueConstraint( - fields=('manufacturer', 'model'), - name='%(app_label)s_%(class)s_unique_manufacturer_model' - ), - ) - verbose_name = _('module type') - verbose_name_plural = _('module types') - - def __str__(self): - return self.model - - @property - def full_name(self): - return f"{self.manufacturer} {self.model}" - - def to_yaml(self): - data = { - 'manufacturer': self.manufacturer.name, - 'model': self.model, - 'part_number': self.part_number, - 'description': self.description, - 'weight': float(self.weight) if self.weight is not None else None, - 'weight_unit': self.weight_unit, - 'comments': self.comments, - } - - # Component templates - if self.consoleporttemplates.exists(): - data['console-ports'] = [ - c.to_yaml() for c in self.consoleporttemplates.all() - ] - if self.consoleserverporttemplates.exists(): - data['console-server-ports'] = [ - c.to_yaml() for c in self.consoleserverporttemplates.all() - ] - if self.powerporttemplates.exists(): - data['power-ports'] = [ - c.to_yaml() for c in self.powerporttemplates.all() - ] - if self.poweroutlettemplates.exists(): - data['power-outlets'] = [ - c.to_yaml() for c in self.poweroutlettemplates.all() - ] - if self.interfacetemplates.exists(): - data['interfaces'] = [ - c.to_yaml() for c in self.interfacetemplates.all() - ] - if self.frontporttemplates.exists(): - data['front-ports'] = [ - c.to_yaml() for c in self.frontporttemplates.all() - ] - if self.rearporttemplates.exists(): - data['rear-ports'] = [ - c.to_yaml() for c in self.rearporttemplates.all() - ] - - return yaml.dump(dict(data), sort_keys=False) - - # # Devices # -class DeviceRole(OrganizationalModel): +class DeviceRole(NestedGroupModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -491,6 +394,8 @@ class DeviceRole(OrganizationalModel): null=True ) + clone_fields = ('parent', 'description') + class Meta: ordering = ('name',) verbose_name = _('device role') @@ -524,23 +429,6 @@ class Platform(OrganizationalModel): verbose_name_plural = _('platforms') -def update_interface_bridges(device, interface_templates, module=None): - """ - Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned - and applies it to the actual interfaces. - """ - for interface_template in interface_templates.exclude(bridge=None): - interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module)) - - if interface_template.bridge: - interface.bridge = Interface.objects.get( - device=device, - name=interface_template.bridge.resolve_name(module=module) - ) - interface.full_clean() - interface.save() - - class Device( ContactsMixin, ImageAttachmentsMixin, @@ -1153,170 +1041,6 @@ class Device( return round(total_weight / 1000, 2) -class Module(PrimaryModel, ConfigContextModel): - """ - A Module represents a field-installable component within a Device which may itself hold multiple device components - (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='modules' - ) - module_bay = models.OneToOneField( - to='dcim.ModuleBay', - on_delete=models.CASCADE, - related_name='installed_module' - ) - module_type = models.ForeignKey( - to='dcim.ModuleType', - on_delete=models.PROTECT, - related_name='instances' - ) - status = models.CharField( - verbose_name=_('status'), - max_length=50, - choices=ModuleStatusChoices, - default=ModuleStatusChoices.STATUS_ACTIVE - ) - serial = models.CharField( - max_length=50, - blank=True, - verbose_name=_('serial number') - ) - asset_tag = models.CharField( - max_length=50, - blank=True, - null=True, - unique=True, - verbose_name=_('asset tag'), - help_text=_('A unique tag used to identify this device') - ) - - clone_fields = ('device', 'module_type', 'status') - - class Meta: - ordering = ('module_bay',) - verbose_name = _('module') - verbose_name_plural = _('modules') - - def __str__(self): - return f'{self.module_bay.name}: {self.module_type} ({self.pk})' - - def get_status_color(self): - return ModuleStatusChoices.colors.get(self.status) - - def clean(self): - super().clean() - - if hasattr(self, "module_bay") and (self.module_bay.device != self.device): - raise ValidationError( - _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( - device=self.device - ) - ) - - # Check for recursion - module = self - module_bays = [] - modules = [] - while module: - if module.pk in modules or module.module_bay.pk in module_bays: - raise ValidationError(_("A module bay cannot belong to a module installed within it.")) - modules.append(module.pk) - module_bays.append(module.module_bay.pk) - module = module.module_bay.module if module.module_bay else None - - def save(self, *args, **kwargs): - is_new = self.pk is None - - super().save(*args, **kwargs) - - adopt_components = getattr(self, '_adopt_components', False) - disable_replication = getattr(self, '_disable_replication', False) - - # We skip adding components if the module is being edited or - # both replication and component adoption is disabled - if not is_new or (disable_replication and not adopt_components): - return - - # Iterate all component types - for templates, component_attribute, component_model in [ - ("consoleporttemplates", "consoleports", ConsolePort), - ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), - ("interfacetemplates", "interfaces", Interface), - ("powerporttemplates", "powerports", PowerPort), - ("poweroutlettemplates", "poweroutlets", PowerOutlet), - ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort), - ("modulebaytemplates", "modulebays", ModuleBay), - ]: - create_instances = [] - update_instances = [] - - # Prefetch installed components - installed_components = { - component.name: component - for component in getattr(self.device, component_attribute).filter(module__isnull=True) - } - - # Get the template for the module type. - for template in getattr(self.module_type, templates).all(): - template_instance = template.instantiate(device=self.device, module=self) - - if adopt_components: - existing_item = installed_components.get(template_instance.name) - - # Check if there's a component with the same name already - if existing_item: - # Assign it to the module - existing_item.module = self - update_instances.append(existing_item) - continue - - # Only create new components if replication is enabled - if not disable_replication: - create_instances.append(template_instance) - - # Set default values for any applicable custom fields - if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): - for component in create_instances: - component.custom_field_data = cf_defaults - - if component_model is not ModuleBay: - component_model.objects.bulk_create(create_instances) - # Emit the post_save signal for each newly created object - for component in create_instances: - post_save.send( - sender=component_model, - instance=component, - created=True, - raw=False, - using='default', - update_fields=None - ) - else: - # ModuleBays must be saved individually for MPTT - for instance in create_instances: - instance.save() - - update_fields = ['module'] - component_model.objects.bulk_update(update_instances, update_fields) - # Emit the post_save signal for each updated object - for component in update_instances: - post_save.send( - sender=component_model, - instance=component, - created=False, - raw=False, - using='default', - update_fields=update_fields - ) - - # Interface bridges have to be set after interface instantiation - update_interface_bridges(self.device, self.module_type.interfacetemplates, self) - - # # Virtual chassis # @@ -1501,7 +1225,6 @@ class MACAddress(PrimaryModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=MACADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', blank=True, diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index a0fc15a25..127dfb9e5 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -3,7 +3,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from dcim.constants import LOCATION_SCOPE_TYPES __all__ = ( 'CachedScopeMixin', @@ -44,7 +43,6 @@ class CachedScopeMixin(models.Model): scope_type = models.ForeignKey( to='contenttypes.ContentType', on_delete=models.PROTECT, - limit_choices_to=models.Q(model__in=LOCATION_SCOPE_TYPES), related_name='+', blank=True, null=True diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py new file mode 100644 index 000000000..432b7afd1 --- /dev/null +++ b/netbox/dcim/models/modules.py @@ -0,0 +1,360 @@ +import jsonschema +import yaml +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.signals import post_save +from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import ValidationError as JSONValidationError + +from dcim.choices import * +from dcim.utils import update_interface_bridges +from extras.models import ConfigContextModel, CustomField +from netbox.models import PrimaryModel +from netbox.models.features import ImageAttachmentsMixin +from netbox.models.mixins import WeightMixin +from utilities.jsonschema import validate_schema +from utilities.string import title +from .device_components import * + +__all__ = ( + 'Module', + 'ModuleType', + 'ModuleTypeProfile', +) + + +class ModuleTypeProfile(PrimaryModel): + """ + A profile which defines the attributes which can be set on one or more ModuleTypes. + """ + name = models.CharField( + verbose_name=_('name'), + max_length=100, + unique=True + ) + schema = models.JSONField( + blank=True, + null=True, + verbose_name=_('schema') + ) + + clone_fields = ('schema',) + + class Meta: + ordering = ('name',) + verbose_name = _('module type profile') + verbose_name_plural = _('module type profiles') + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # Validate the schema definition + if self.schema is not None: + try: + validate_schema(self.schema) + except ValidationError as e: + raise ValidationError({ + 'schema': e.message, + }) + + +class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + profile = models.ForeignKey( + to='dcim.ModuleTypeProfile', + on_delete=models.PROTECT, + related_name='module_types', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + verbose_name=_('model'), + max_length=100 + ) + part_number = models.CharField( + verbose_name=_('part number'), + max_length=50, + blank=True, + help_text=_('Discrete part number (optional)') + ) + airflow = models.CharField( + verbose_name=_('airflow'), + max_length=50, + choices=ModuleAirflowChoices, + blank=True, + null=True + ) + attribute_data = models.JSONField( + blank=True, + null=True, + verbose_name=_('attributes') + ) + + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') + prerequisite_models = ( + 'dcim.Manufacturer', + ) + + class Meta: + ordering = ('profile', 'manufacturer', 'model') + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + ) + verbose_name = _('module type') + verbose_name_plural = _('module types') + + def __str__(self): + return self.model + + @property + def full_name(self): + return f"{self.manufacturer} {self.model}" + + @property + def attributes(self): + """ + Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile. + """ + if not self.attribute_data or self.profile is None or not self.profile.schema: + return {} + attrs = {} + for name, options in self.profile.schema.get('properties', {}).items(): + key = options.get('title', title(name)) + attrs[key] = self.attribute_data.get(name) + return dict(sorted(attrs.items())) + + def clean(self): + super().clean() + + # Validate any attributes against the assigned profile's schema + if self.profile: + try: + jsonschema.validate(self.attribute_data, schema=self.profile.schema) + except JSONValidationError as e: + raise ValidationError(_("Invalid schema: {error}").format(error=e)) + else: + self.attribute_data = None + + def to_yaml(self): + data = { + 'profile': self.profile.name if self.profile else None, + 'manufacturer': self.manufacturer.name, + 'model': self.model, + 'part_number': self.part_number, + 'description': self.description, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, + 'comments': self.comments, + } + + # Component templates + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + c.to_yaml() for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + c.to_yaml() for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + c.to_yaml() for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + c.to_yaml() for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + c.to_yaml() for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + c.to_yaml() for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + c.to_yaml() for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=ModuleStatusChoices, + default=ModuleStatusChoices.STATUS_ACTIVE + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name=_('serial number') + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name=_('asset tag'), + help_text=_('A unique tag used to identify this device') + ) + + clone_fields = ('device', 'module_type', 'status') + + class Meta: + ordering = ('module_bay',) + verbose_name = _('module') + verbose_name_plural = _('modules') + + def __str__(self): + return f'{self.module_bay.name}: {self.module_type} ({self.pk})' + + def get_status_color(self): + return ModuleStatusChoices.colors.get(self.status) + + def clean(self): + super().clean() + + if hasattr(self, "module_bay") and (self.module_bay.device != self.device): + raise ValidationError( + _("Module must be installed within a module bay belonging to the assigned device ({device}).").format( + device=self.device + ) + ) + + # Check for recursion + module = self + module_bays = [] + modules = [] + while module: + if module.pk in modules or module.module_bay.pk in module_bays: + raise ValidationError(_("A module bay cannot belong to a module installed within it.")) + modules.append(module.pk) + module_bays.append(module.module_bay.pk) + module = module.module_bay.module if module.module_bay else None + + def save(self, *args, **kwargs): + is_new = self.pk is None + + super().save(*args, **kwargs) + + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return + + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort), + ("modulebaytemplates", "modulebays", ModuleBay), + ]: + create_instances = [] + update_instances = [] + + # Prefetch installed components + installed_components = { + component.name: component + for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + + if adopt_components: + existing_item = installed_components.get(template_instance.name) + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: + create_instances.append(template_instance) + + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): + for component in create_instances: + component.custom_field_data = cf_defaults + + if component_model is not ModuleBay: + component_model.objects.bulk_create(create_instances) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + else: + # ModuleBays must be saved individually for MPTT + for instance in create_instances: + instance.save() + + update_fields = ['module'] + component_model.objects.bulk_update(update_instances, update_fields) + # Emit the post_save signal for each updated object + for component in update_instances: + post_save.send( + sender=component_model, + instance=component, + created=False, + raw=False, + using='default', + update_fields=update_fields + ) + + # Interface bridges have to be set after interface instantiation + update_interface_bridges(self.device, self.module_type.interfacetemplates, self) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 7ecbd5d5f..390d9ae9e 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -73,6 +73,12 @@ class RackBase(WeightMixin, PrimaryModel): null=True, help_text=_('Outer dimension of rack (width)') ) + outer_height = models.PositiveSmallIntegerField( + verbose_name=_('outer height'), + blank=True, + null=True, + help_text=_('Outer dimension of rack (height)') + ) outer_depth = models.PositiveSmallIntegerField( verbose_name=_('outer depth'), blank=True, @@ -140,7 +146,7 @@ class RackType(RackBase): ) clone_fields = ( - 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', ) prerequisite_models = ( @@ -173,8 +179,8 @@ class RackType(RackBase): super().clean() # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -188,7 +194,7 @@ class RackType(RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) @@ -235,8 +241,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): """ # Fields which cannot be set locally if a RackType is assigned RACKTYPE_FIELDS = ( - 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', + 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', + 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight', ) form_factor = models.CharField( @@ -329,7 +335,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', + 'weight_unit', ) prerequisite_models = ( 'dcim.Site', @@ -364,8 +371,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): raise ValidationError(_("Assigned location must belong to parent site ({site}).").format(site=self.site)) # Validate outer dimensions and unit - if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: - raise ValidationError(_("Must specify a unit when setting an outer width/depth")) + if any([self.outer_width, self.outer_depth, self.outer_height]) and not self.outer_unit: + raise ValidationError(_("Must specify a unit when setting an outer dimension")) # Validate max_weight and weight_unit if self.max_weight and not self.weight_unit: @@ -414,7 +421,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): self._abs_max_weight = None # Clear unit if outer width & depth are not set - if self.outer_width is None and self.outer_depth is None: + if not any([self.outer_width, self.outer_depth, self.outer_height]): self.outer_unit = None super().save(*args, **kwargs) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b964421de..33b666df7 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -144,6 +144,7 @@ class LocationIndex(SearchIndex): ('facility', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('site', 'status', 'tenant', 'facility', 'description') @@ -182,6 +183,17 @@ class ModuleBayIndex(SearchIndex): display_attrs = ('device', 'label', 'position', 'description') +@register_search +class ModuleTypeProfileIndex(SearchIndex): + model = models.ModuleTypeProfile + fields = ( + ('name', 100), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('name', 'description') + + @register_search class ModuleTypeIndex(SearchIndex): model = models.ModuleType @@ -224,7 +236,7 @@ class PowerOutletIndex(SearchIndex): ('label', 200), ('description', 500), ) - display_attrs = ('device', 'label', 'type', 'description') + display_attrs = ('device', 'label', 'type', 'status', 'description') @register_search @@ -317,6 +329,7 @@ class RegionIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') @@ -343,6 +356,7 @@ class SiteGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('parent', 'description') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d4f2f74b3..5cfb31750 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -59,7 +59,7 @@ MACADDRESS_COPY_BUTTON = """ # class DeviceRoleTable(NetBoxTable): - name = tables.Column( + name = columns.MPTTColumn( verbose_name=_('Name'), linkify=True ) @@ -520,6 +520,9 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): verbose_name=_('Power Port'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status'), + ) color = columns.ColorColumn() tags = columns.TagColumn( url_name='dcim:poweroutlet_list' @@ -530,9 +533,11 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', 'color', 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', - 'tags', 'created', 'last_updated', + 'tags', 'created', 'last_updated', 'status', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', ) - default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description') class DevicePowerOutletTable(PowerOutletTable): @@ -550,9 +555,11 @@ class DevicePowerOutletTable(PowerOutletTable): fields = ( 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'status', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'cable', 'connection', + 'pk', 'name', 'label', 'type', 'status', 'color', 'power_port', 'feed_leg', 'description', 'cable', + 'connection', ) diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6bd0d53b5..52edea8b4 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,25 +1,64 @@ from django.utils.translation import gettext_lazy as _ import django_tables2 as tables -from dcim.models import Module, ModuleType +from dcim.models import Module, ModuleType, ModuleTypeProfile from netbox.tables import NetBoxTable, columns -from .template_code import WEIGHT +from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( 'ModuleTable', + 'ModuleTypeProfileTable', 'ModuleTypeTable', ) +class ModuleTypeProfileTable(NetBoxTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + attributes = columns.TemplateColumn( + template_code=MODULETYPEPROFILE_ATTRIBUTES, + accessor=tables.A('schema__properties'), + orderable=False, + verbose_name=_('Attributes') + ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) + tags = columns.TagColumn( + url_name='dcim:moduletypeprofile_list' + ) + + class Meta(NetBoxTable.Meta): + model = ModuleTypeProfile + fields = ( + 'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'description', 'attributes', + ) + + class ModuleTypeTable(NetBoxTable): - model = tables.Column( - linkify=True, - verbose_name=_('Module Type') + profile = tables.Column( + verbose_name=_('Profile'), + linkify=True ) manufacturer = tables.Column( verbose_name=_('Manufacturer'), linkify=True ) + model = tables.Column( + linkify=True, + verbose_name=_('Module Type') + ) + weight = columns.TemplateColumn( + verbose_name=_('Weight'), + template_code=WEIGHT, + order_by=('_abs_weight', 'weight_unit') + ) + attributes = columns.DictColumn() instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, @@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) - weight = columns.TemplateColumn( - verbose_name=_('Weight'), - template_code=WEIGHT, - order_by=('_abs_weight', 'weight_unit') - ) class Meta(NetBoxTable.Meta): model = ModuleType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', + 'attributes', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', + 'pk', 'model', 'profile', 'manufacturer', 'part_number', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index dbd99ca24..ee40056de 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -5,7 +5,7 @@ from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole, RackType from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from .template_code import WEIGHT +from .template_code import OUTER_UNIT, WEIGHT __all__ = ( 'RackTable', @@ -62,12 +62,16 @@ class RackTypeTable(NetBoxTable): template_code="{{ value }}U", verbose_name=_('Height') ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -96,8 +100,8 @@ class RackTypeTable(NetBoxTable): model = RackType fields = ( 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', - 'instance_count', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', + 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', @@ -159,12 +163,16 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:rack_list' ) - outer_width = tables.TemplateColumn( - template_code="{{ record.outer_width }} {{ record.outer_unit }}", + outer_width = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Width') ) - outer_depth = tables.TemplateColumn( - template_code="{{ record.outer_depth }} {{ record.outer_unit }}", + outer_height = columns.TemplateColumn( + template_code=OUTER_UNIT, + verbose_name=_('Outer Height') + ) + outer_depth = columns.TemplateColumn( + template_code=OUTER_UNIT, verbose_name=_('Outer Depth') ) weight = columns.TemplateColumn( @@ -183,8 +191,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', - 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', + 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height', diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 77844f086..7d2f0e0cc 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -32,12 +32,15 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:region_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Region fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -59,12 +62,15 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', - 'actions', + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -153,12 +159,15 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): actions = columns.ActionsColumn( extra_buttons=LOCATION_BUTTONS ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = Location fields = ( 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', - 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description' diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4b51cd06a..3c4ca7bd4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -109,6 +109,11 @@ LOCATION_BUTTONS = """ """ +OUTER_UNIT = """ +{% load helpers %} +{% if value %}{{ value }} {{ record.outer_unit }}{% endif %} +""" + # # Device component templatebuttons # @@ -563,3 +568,7 @@ MODULEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULETYPEPROFILE_ATTRIBUTES = """ +{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 99a446aef..8007d9161 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,3 +1,5 @@ +import json + from django.test import override_settings from django.urls import reverse from django.utils.translation import gettext as _ @@ -72,6 +74,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): { 'name': 'Region 4', 'slug': 'region-4', + 'comments': 'this is region 4, not region 5', }, { 'name': 'Region 5', @@ -84,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') @@ -101,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): @@ -210,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!' ), ) @@ -225,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], @@ -248,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', @@ -255,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', @@ -578,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase): model = ModuleType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -616,6 +629,70 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase): ] +class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase): + model = ModuleTypeProfile + brief_fields = ['description', 'display', 'id', 'name', 'url'] + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + create_data = [ + { + 'name': 'Module Type Profile 4', + 'schema': SCHEMAS[0], + }, + { + 'name': 'Module Type Profile 5', + 'schema': SCHEMAS[1], + }, + { + 'name': 'Module Type Profile 6', + 'schema': SCHEMAS[2], + }, + ] + bulk_update_data = { + 'description': 'New description', + 'comments': 'New comments', + } + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['description', 'display', 'id', 'name', 'url'] @@ -1136,7 +1213,9 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole - brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = [ + '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count' + ] create_data = [ { 'name': 'Device Role 4', @@ -1161,12 +1240,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - roles = ( - DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'), - DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'), - DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'), - ) - DeviceRole.objects.bulk_create(roles) + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000') + DeviceRole.objects.create(name='Device Role 2', slug='device-role-2', color='00ff00') + DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff') class PlatformTest(APIViewTestCases.APIViewTestCase): @@ -1239,7 +1315,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'), DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') @@ -1748,6 +1825,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] + def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}): + device = Device.objects.first() + data = { + 'device': device.pk, + 'name': 'Interface 1', + 'type': InterfaceTypeChoices.TYPE_1GE_FIXED, + } + data.update({'mode': mode}) + data.update(invalid_data) + + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + content = json.loads(response.content) + for key in invalid_data.keys(): + self.assertIn(key, content) + self.assertIsNone(content.get('data')) + def test_bulk_delete_child_interfaces(self): interface1 = Interface.objects.get(name='Interface 1') device = interface1.device @@ -1775,6 +1869,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase self.client.delete(self._get_list_url(), data, format='json', **self.header) self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted + def test_create_child_interfaces_mode_invalid_data(self): + """ + POST data to test interface mode check and invalid tagged/untagged VLANS. + """ + self.add_permissions('dcim.add_interface') + + vlans = VLAN.objects.all()[0:3] + + # Routed mode, untagged, tagged and qinq service vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + 'qinq_svlan': vlans[2].pk + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + # Routed mode, untagged and tagged vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + # Routed mode, untagged vlan + invalid_data = { + 'untagged_vlan': vlans[0].pk, + } + self._perform_interface_test_with_invalid_data(None, invalid_data) + + invalid_data = { + 'tagged_vlans': [vlans[1].pk, vlans[2].pk], + } + # Routed mode, qinq service vlan + self._perform_interface_test_with_invalid_data(None, invalid_data) + # Access mode, tagged vlans + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data) + # All tagged mode, tagged vlans + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data) + + invalid_data = { + 'qinq_svlan': vlans[0].pk, + } + # Routed mode, qinq service vlan + self._perform_interface_test_with_invalid_data(None, invalid_data) + # Access mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data) + # Tagged mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data) + # Tagged-all mode, qinq service vlan + self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data) + class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ede1e2a09..b56ea3919 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -67,9 +67,15 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): parent_regions = ( - Region(name='Region 1', slug='region-1', description='foobar1'), - Region(name='Region 2', slug='region-2', description='foobar2'), - Region(name='Region 3', slug='region-3', description='foobar3'), + Region( + name='Region 1', slug='region-1', description='foobar1', comments="There's nothing that", + ), + Region( + name='Region 2', slug='region-2', description='foobar2', comments='a hundred men or more', + ), + Region( + name='Region 3', slug='region-3', description='foobar3', comments='could ever do' + ), ) for region in parent_regions: region.save() @@ -100,6 +106,13 @@ class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'there'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'hundred men could'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + def test_name(self): params = {'name': ['Region 1', 'Region 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -148,13 +161,17 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=parent_groups[1]), SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=parent_groups[1]), SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=parent_groups[2]), - SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2]), + SiteGroup( + name='Site Group 3B', slug='site-group-3b', parent=parent_groups[2], comments='this is a parent group', + ), ) for site_group in groups: site_group.save() child_groups = ( - SiteGroup(name='Site Group 1A1', slug='site-group-1a1', parent=groups[0]), + SiteGroup( + name='Site Group 1A1', slug='site-group-1a1', parent=groups[0], comments='this is a child group', + ), SiteGroup(name='Site Group 1B1', slug='site-group-1b1', parent=groups[1]), SiteGroup(name='Site Group 2A1', slug='site-group-2a1', parent=groups[2]), SiteGroup(name='Site Group 2B1', slug='site-group-2b1', parent=groups[3]), @@ -168,6 +185,13 @@ class SiteGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': 'child'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Site Group 1', 'Site Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -401,6 +425,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_PLANNED, facility='Facility 1', description='foobar1', + comments='', ), Location( name='Location 2A', @@ -410,6 +435,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_STAGING, facility='Facility 2', description='foobar2', + comments='First comment!', ), Location( name='Location 3A', @@ -419,6 +445,7 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): status=LocationStatusChoices.STATUS_DECOMMISSIONING, facility='Facility 3', description='foobar3', + comments='_This_ is a **bold comment**', ), ) for location in locations: @@ -436,6 +463,13 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'this'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_name(self): params = {'name': ['Location 1', 'Location 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -551,6 +585,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=1, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=100, @@ -569,6 +604,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=2, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=200, @@ -587,6 +623,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=3, desc_units=True, outer_width=300, + outer_height=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, mounting_depth=300, @@ -647,6 +684,10 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'outer_width': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_height(self): + params = {'outer_height': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_depth(self): params = {'outer_depth': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -730,6 +771,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=1, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=100, @@ -748,6 +790,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): starting_unit=2, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, mounting_depth=200, @@ -797,6 +840,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=42, desc_units=False, outer_width=100, + outer_height=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, @@ -820,6 +864,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=43, desc_units=False, outer_width=200, + outer_height=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, @@ -843,6 +888,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): u_height=44, desc_units=True, outer_width=300, + outer_height=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, @@ -923,6 +969,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'outer_width': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_height(self): + params = {'outer_height': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_outer_depth(self): params = {'outer_depth': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1436,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() filterset = ModuleTypeFilterSet + ignore_fields = ['attribute_data'] + + PROFILE_SCHEMA = { + "properties": { + "string": {"type": "string"}, + "integer": {"type": "integer"}, + "number": {"type": "number"}, + "boolean": {"type": "boolean"}, + } + } @classmethod def setUpTestData(cls): @@ -1446,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ) Manufacturer.objects.bulk_create(manufacturers) + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.PROFILE_SCHEMA + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.PROFILE_SCHEMA + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.PROFILE_SCHEMA + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) module_types = ( ModuleType( @@ -1455,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=10, weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar1', - airflow=ModuleAirflowChoices.FRONT_TO_REAR + airflow=ModuleAirflowChoices.FRONT_TO_REAR, + profile=module_type_profiles[0], + attribute_data={ + 'string': 'string1', + 'integer': 1, + 'number': 1.0, + 'boolean': True, + } ), ModuleType( manufacturer=manufacturers[1], @@ -1464,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=20, weight_unit=WeightUnitChoices.UNIT_POUND, description='foobar2', - airflow=ModuleAirflowChoices.REAR_TO_FRONT + airflow=ModuleAirflowChoices.REAR_TO_FRONT, + profile=module_type_profiles[1], + attribute_data={ + 'string': 'string2', + 'integer': 2, + 'number': 2.0, + 'boolean_': False, + } ), ModuleType( manufacturer=manufacturers[2], @@ -1472,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM, - description='foobar3' + description='foobar3', + profile=module_type_profiles[2], + attribute_data={ + 'string': 'string3', + 'integer': 3, + 'number': 3.0, + 'boolean': None, + } ), ) ModuleType.objects.bulk_create(module_types) @@ -1591,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'airflow': RackAirflowChoices.FRONT_TO_REAR} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_profile(self): + profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2] + params = {'profile_id': [profiles[0].pk, profiles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'profile': [profiles[0].name, profiles[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_profile_attributes(self): + params = {'attr_string': 'string1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_integer': '1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_number': '2.0'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'attr_boolean': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + +class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ModuleTypeProfile.objects.all() + filterset = ModuleTypeProfileFilterSet + ignore_fields = ['schema'] + + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + description='foobar1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + description='foobar2 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + description='foobar3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Module Type Profile 1', 'Module Type Profile 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsolePortTemplate.objects.all() @@ -2141,12 +2313,65 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - roles = ( + parent_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'), DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'), - DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False), + DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False) ) - DeviceRole.objects.bulk_create(roles) + for role in parent_roles: + role.save() + + roles = ( + DeviceRole( + name='Device Role 1A', + slug='device-role-1a', + color='aa0000', + vm_role=True, + parent=parent_roles[0] + ), + DeviceRole( + name='Device Role 2A', + slug='device-role-2a', + color='00aa00', + vm_role=True, + parent=parent_roles[1] + ), + DeviceRole( + name='Device Role 3A', + slug='device-role-3a', + color='0000aa', + vm_role=False, + parent=parent_roles[2] + ) + ) + for role in roles: + role.save() + + child_roles = ( + DeviceRole( + name='Device Role 1A1', + slug='device-role-1a1', + color='bb0000', + vm_role=True, + parent=roles[0] + ), + DeviceRole( + name='Device Role 2A1', + slug='device-role-2a1', + color='00bb00', + vm_role=True, + parent=roles[1] + ), + DeviceRole( + name='Device Role 3A1', + slug='device-role-3a1', + color='0000bb', + vm_role=False, + parent=roles[2] + ) + ) + for role in child_roles: + role.save() def test_q(self): params = {'q': 'foobar1'} @@ -2166,14 +2391,28 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): def test_vm_role(self): params = {'vm_role': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'vm_role': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + roles = DeviceRole.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'parent': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_ancestor(self): + roles = DeviceRole.objects.filter(parent__isnull=True)[:2] + params = {'ancestor_id': [roles[0].pk, roles[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'ancestor': [roles[0].slug, roles[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Platform.objects.all() @@ -2259,7 +2498,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), @@ -2924,7 +3164,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3136,7 +3377,8 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3354,7 +3596,8 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3598,7 +3841,8 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3684,6 +3928,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First', color='ff0000', + status=PowerOutletStatusChoices.STATUS_ENABLED, ), PowerOutlet( device=devices[1], @@ -3693,6 +3938,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second', color='00ff00', + status=PowerOutletStatusChoices.STATUS_DISABLED, ), PowerOutlet( device=devices[2], @@ -3702,6 +3948,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third', color='0000ff', + status=PowerOutletStatusChoices.STATUS_FAULTY, ), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -3796,6 +4043,23 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_status(self): + params = {'status': [PowerOutletStatusChoices.STATUS_ENABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [PowerOutletStatusChoices.STATUS_DISABLED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [PowerOutletStatusChoices.STATUS_FAULTY]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'status': [ + PowerOutletStatusChoices.STATUS_ENABLED, + PowerOutletStatusChoices.STATUS_DISABLED, + PowerOutletStatusChoices.STATUS_FAULTY, + ]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() @@ -3843,7 +4107,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -4422,7 +4687,8 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -4694,7 +4960,8 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -4934,7 +5201,8 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -5106,7 +5374,8 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -5241,7 +5510,8 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() regions = ( Region(name='Region 1', slug='region-1'), diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 7a57bf3f0..0067acaaf 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,8 +1,11 @@ from django.test import TestCase -from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices +from dcim.choices import ( + DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices, PowerOutletStatusChoices +) from dcim.forms import * from dcim.models import * +from ipam.models import VLAN from utilities.testing import create_test_device from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -11,6 +14,56 @@ def get_id(model, slug): return model.objects.get(slug=slug).id +class PowerOutletFormTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.site = site = Site.objects.create(name='Site 1', slug='site-1') + cls.manufacturer = manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + cls.role = role = DeviceRole.objects.create( + name='Device Role 1', slug='device-role-1', color='ff0000' + ) + cls.device_type = device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1 + ) + cls.rack = rack = Rack.objects.create(name='Rack 1', site=site) + cls.device = Device.objects.create( + name='Device 1', device_type=device_type, role=role, site=site, rack=rack, position=1 + ) + + def test_status_is_required(self): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': 'New Enabled Outlet', + }) + self.assertFalse(form.is_valid()) + self.assertIn('status', form.errors) + + def test_status_must_be_defined_choice(self): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': 'New Enabled Outlet', + 'status': 'this isn\'t a defined choice', + }) + self.assertFalse(form.is_valid()) + self.assertIn('status', form.errors) + self.assertTrue(form.errors['status'][-1].startswith('Select a valid choice.')) + + def test_status_recognizes_choices(self): + for index, choice in enumerate(PowerOutletStatusChoices.CHOICES): + form = PowerOutletForm(data={ + 'device': self.device, + 'module': None, + 'name': f'New Enabled Outlet {index + 1}', + 'status': choice[0], + }) + self.assertEqual({}, form.errors) + self.assertTrue(form.is_valid()) + instance = form.save() + self.assertEqual(instance.status, choice[0]) + + class DeviceTestCase(TestCase): @classmethod @@ -117,11 +170,23 @@ class DeviceTestCase(TestCase): self.assertIn('position', form.errors) -class LabelTestCase(TestCase): +class InterfaceTestCase(TestCase): @classmethod def setUpTestData(cls): cls.device = create_test_device('Device 1') + cls.vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + ) + VLAN.objects.bulk_create(cls.vlans) + cls.interface = Interface.objects.create( + device=cls.device, + name='Interface 1', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + mode=InterfaceModeChoices.MODE_TAGGED, + ) def test_interface_label_count_valid(self): """ @@ -151,3 +216,152 @@ class LabelTestCase(TestCase): self.assertFalse(form.is_valid()) self.assertIn('label', form.errors) + + def test_create_interface_mode_valid_data(self): + """ + Test that saving valid interface mode and tagged/untagged vlans works properly + """ + + # Validate access mode + data = { + 'device': self.device.pk, + 'name': 'ethernet1/1', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'untagged_vlan': self.vlans[0].pk + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + + # Validate tagged vlans + data = { + 'device': self.device.pk, + 'name': 'ethernet1/2', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + self.assertTrue(form.is_valid()) + + # Validate tagged vlans + data = { + 'device': self.device.pk, + 'name': 'ethernet1/3', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'untagged_vlan': self.vlans[0].pk, + } + form = InterfaceCreateForm(data) + self.assertTrue(form.is_valid()) + + def test_create_interface_mode_access_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/4', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_access_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/5', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_ACCESS, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data, instance=self.interface) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_create_interface_mode_tagged_all_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/6', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_tagged_all_invalid_data(self): + """ + Test that saving invalid interface mode and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/7', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': InterfaceModeChoices.MODE_TAGGED_ALL, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data) + self.assertTrue(form.is_valid()) + self.assertIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_create_interface_mode_routed_invalid_data(self): + """ + Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'ethernet1/6', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': None, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceCreateForm(data) + + self.assertTrue(form.is_valid()) + self.assertNotIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) + + def test_edit_interface_mode_routed_invalid_data(self): + """ + Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly + """ + data = { + 'device': self.device.pk, + 'name': 'Ethernet 1/7', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'mode': None, + 'untagged_vlan': self.vlans[0].pk, + 'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk] + } + form = InterfaceForm(data) + self.assertTrue(form.is_valid()) + self.assertNotIn('untagged_vlan', form.cleaned_data.keys()) + self.assertNotIn('tagged_vlans', form.cleaned_data.keys()) + self.assertNotIn('qinq_svlan', form.cleaned_data.keys()) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ff1eddd56..66f52b1bf 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -346,7 +346,8 @@ class DeviceTestCase(TestCase): DeviceRole(name='Test Role 1', slug='test-role-1'), DeviceRole(name='Test Role 2', slug='test-role-2'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() # Create a CustomField with a default value & assign it to all component models cf1 = CustomField.objects.create(name='cf1', default='foo') @@ -465,7 +466,8 @@ class DeviceTestCase(TestCase): device=device, name='Power Outlet 1', power_port=powerport, - feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A + feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, + status=PowerOutletStatusChoices.STATUS_ENABLED, ) self.assertEqual(poweroutlet.cf['cf1'], 'foo') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b84217882..3c43d1834 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal from zoneinfo import ZoneInfo @@ -25,8 +26,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 +43,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 +62,7 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'This comment is super exciting!!!', } @@ -69,7 +74,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 +89,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 +209,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='', ), Location( name='Location 2', @@ -209,6 +217,7 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant, + comments='First comment!', ), Location( name='Location 3', @@ -216,6 +225,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 +242,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', } @@ -1294,6 +1306,79 @@ front-ports: self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') +class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = ModuleTypeProfile + + SCHEMAS = [ + { + "properties": { + "foo": { + "type": "string" + } + } + }, + { + "properties": { + "foo": { + "type": "integer" + } + } + }, + { + "properties": { + "foo": { + "type": "boolean" + } + } + }, + ] + + @classmethod + def setUpTestData(cls): + module_type_profiles = ( + ModuleTypeProfile( + name='Module Type Profile 1', + schema=cls.SCHEMAS[0] + ), + ModuleTypeProfile( + name='Module Type Profile 2', + schema=cls.SCHEMAS[1] + ), + ModuleTypeProfile( + name='Module Type Profile 3', + schema=cls.SCHEMAS[2] + ), + ) + ModuleTypeProfile.objects.bulk_create(module_type_profiles) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Module Type Profile X', + 'description': 'A new profile', + 'schema': json.dumps(cls.SCHEMAS[0]), + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,schema", + f"Module Type Profile 4,{json.dumps(cls.SCHEMAS[0])}", + f"Module Type Profile 5,{json.dumps(cls.SCHEMAS[1])}", + f"Module Type Profile 6,{json.dumps(cls.SCHEMAS[2])}", + ) + + cls.csv_update_data = ( + "id,description", + f"{module_type_profiles[0].pk},New description", + f"{module_type_profiles[1].pk},New description", + f"{module_type_profiles[2].pk},New description", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + # # DeviceType components # @@ -1683,13 +1768,16 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - roles = ( + roles = [ DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), DeviceRole(name='Device Role 3', slug='device-role-3'), - ) - DeviceRole.objects.bulk_create(roles) + DeviceRole(name='Device Role 4', slug='device-role-4'), + ] + for role in roles: + role.save() + roles.append(DeviceRole.objects.create(name='Device Role 5', slug='device-role-5', parent=roles[3])) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -1713,6 +1801,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): f"{roles[0].pk},Device Role 7,New description7", f"{roles[1].pk},Device Role 8,New description8", f"{roles[2].pk},Device Role 9,New description9", + f"{roles[4].pk},Device Role 10,New description10", ) cls.bulk_edit_data = { @@ -1798,7 +1887,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), ) - DeviceRole.objects.bulk_create(roles) + for role in roles: + role.save() platforms = ( Platform(name='Platform 1', slug='platform-1'), @@ -2513,6 +2603,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Power Outlet X', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', @@ -2523,6 +2614,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): 'device': device.pk, 'name': 'Power Outlet [4-6]', 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'A power outlet', @@ -2531,6 +2623,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_edit_data = { 'type': PowerOutletTypeChoices.TYPE_IEC_C15, + 'status': PowerOutletStatusChoices.STATUS_ENABLED, 'power_port': powerports[1].pk, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'description': 'New description', diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index bcfd32707..122593834 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -37,6 +37,9 @@ urlpatterns = [ path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))), path('device-types/{default}
').format(default=DEFAULT_MIME_TYPE),
+ )
+ 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,
+ blank=True,
+ help_text=_('Extension to append to the rendered filename')
+ )
+ as_attachment = models.BooleanField(
+ verbose_name=_('as attachment'),
+ default=True,
+ help_text=_("Download file as attachment")
+ )
+
+ class Meta:
+ abstract = True
+
+ def get_context(self, context=None, queryset=None):
+ raise NotImplementedError(_("{class_name} must implement a get_context() method.").format(
+ class_name=self.__class__
+ ))
+
+ def render(self, context=None, queryset=None):
+ """
+ Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary.
+ """
+ context = self.get_context(context=context, queryset=queryset)
+ env_params = self.environment_params or {}
+ output = render_jinja2(self.template_code, context, env_params)
+
+ # Replace CRLF-style line terminators
+ output = output.replace('\r\n', '\n')
+
+ return output
+
+ def render_to_response(self, context=None, queryset=None):
+ output = self.render(context=context, queryset=queryset)
+ mime_type = self.mime_type or DEFAULT_MIME_TYPE
+
+ # Build the response
+ response = HttpResponse(output, content_type=mime_type)
+
+ if self.as_attachment:
+ extension = f'.{self.file_extension}' if self.file_extension else ''
+ if self.file_name:
+ filename = self.file_name
+ elif queryset:
+ filename = filename_from_model(queryset.model)
+ elif context:
+ filename = filename_from_object(context)
+ else:
+ filename = "output"
+ response['Content-Disposition'] = f'attachment; filename="{filename}{extension}"'
+
+ return response
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index d3e443b14..76fad1082 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -6,7 +6,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.contrib.postgres.fields import ArrayField
from django.core.validators import ValidationError
from django.db import models
-from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -17,11 +16,12 @@ from extras.choices import *
from extras.conditions import ConditionSet
from extras.constants import *
from extras.utils import image_upload
+from extras.models.mixins import RenderTemplateMixin
from netbox.config import get_config
from netbox.events import get_event_type_choices
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
- CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
+ CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
)
from utilities.html import clean_html
from utilities.jinja2 import render_jinja2
@@ -382,7 +382,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
}
-class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
+class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin):
object_types = models.ManyToManyField(
to='core.ObjectType',
related_name='export_templates',
@@ -397,32 +397,9 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
max_length=200,
blank=True
)
- template_code = models.TextField(
- help_text=_(
- "Jinja2 template code. The list of objects being exported is passed as a context variable named "
- "queryset
."
- )
- )
- mime_type = models.CharField(
- max_length=50,
- blank=True,
- verbose_name=_('MIME type'),
- help_text=_('Defaults to text/plain; charset=utf-8
')
- )
- file_extension = models.CharField(
- verbose_name=_('file extension'),
- max_length=15,
- blank=True,
- help_text=_('Extension to append to the rendered filename')
- )
- as_attachment = models.BooleanField(
- verbose_name=_('as attachment'),
- default=True,
- help_text=_("Download file as attachment")
- )
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:
@@ -455,37 +432,16 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
self.template_code = self.data_file.data_as_string
sync_data.alters_data = True
- def render(self, queryset):
- """
- Render the contents of the template.
- """
- context = {
- 'queryset': queryset
+ def get_context(self, context=None, queryset=None):
+ _context = {
+ 'queryset': queryset,
}
- output = render_jinja2(self.template_code, context)
- # Replace CRLF-style line terminators
- output = output.replace('\r\n', '\n')
+ # Apply the provided context data, if any
+ if context is not None:
+ _context.update(context)
- return output
-
- def render_to_response(self, queryset):
- """
- Render the template to an HTTP response, delivered as a named file attachment
- """
- output = self.render(queryset)
- mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
-
- # Build the response
- 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}"'
-
- return response
+ return _context
class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py
deleted file mode 100644
index 68d37de7f..000000000
--- a/netbox/extras/models/staging.py
+++ /dev/null
@@ -1,150 +0,0 @@
-import logging
-import warnings
-
-from django.contrib.contenttypes.fields import GenericForeignKey
-from django.db import models, transaction
-from django.utils.translation import gettext_lazy as _
-from mptt.models import MPTTModel
-
-from extras.choices import ChangeActionChoices
-from netbox.models import ChangeLoggedModel
-from netbox.models.features import *
-from utilities.serialization import deserialize_object
-
-__all__ = (
- 'Branch',
- 'StagedChange',
-)
-
-logger = logging.getLogger('netbox.staging')
-
-
-class Branch(ChangeLoggedModel):
- """
- A collection of related StagedChanges.
- """
- name = models.CharField(
- verbose_name=_('name'),
- max_length=100,
- unique=True
- )
- description = models.CharField(
- verbose_name=_('description'),
- max_length=200,
- blank=True
- )
- user = models.ForeignKey(
- to='users.User',
- on_delete=models.SET_NULL,
- blank=True,
- null=True
- )
-
- class Meta:
- ordering = ('name',)
- verbose_name = _('branch')
- verbose_name_plural = _('branches')
-
- def __init__(self, *args, **kwargs):
- warnings.warn(
- 'The staged changes functionality has been deprecated and will be removed in a future release.',
- DeprecationWarning
- )
- super().__init__(*args, **kwargs)
-
- def __str__(self):
- return f'{self.name} ({self.pk})'
-
- def merge(self):
- logger.info(f'Merging changes in branch {self}')
- with transaction.atomic():
- for change in self.staged_changes.all():
- change.apply()
- self.staged_changes.all().delete()
-
-
-class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
- """
- The prepared creation, modification, or deletion of an object to be applied to the active database at a
- future point.
- """
- branch = models.ForeignKey(
- to=Branch,
- on_delete=models.CASCADE,
- related_name='staged_changes'
- )
- action = models.CharField(
- verbose_name=_('action'),
- max_length=20,
- choices=ChangeActionChoices
- )
- object_type = models.ForeignKey(
- to='contenttypes.ContentType',
- on_delete=models.CASCADE,
- related_name='+'
- )
- object_id = models.PositiveBigIntegerField(
- blank=True,
- null=True
- )
- object = GenericForeignKey(
- ct_field='object_type',
- fk_field='object_id'
- )
- data = models.JSONField(
- verbose_name=_('data'),
- blank=True,
- null=True
- )
-
- class Meta:
- ordering = ('pk',)
- indexes = (
- models.Index(fields=('object_type', 'object_id')),
- )
- verbose_name = _('staged change')
- verbose_name_plural = _('staged changes')
-
- def __init__(self, *args, **kwargs):
- warnings.warn(
- 'The staged changes functionality has been deprecated and will be removed in a future release.',
- DeprecationWarning
- )
- super().__init__(*args, **kwargs)
-
- def __str__(self):
- action = self.get_action_display()
- app_label, model_name = self.object_type.natural_key()
- return f"{action} {app_label}.{model_name} ({self.object_id})"
-
- @property
- def model(self):
- return self.object_type.model_class()
-
- def apply(self):
- """
- Apply the staged create/update/delete action to the database.
- """
- if self.action == ChangeActionChoices.ACTION_CREATE:
- instance = deserialize_object(self.model, self.data, pk=self.object_id)
- logger.info(f'Creating {self.model._meta.verbose_name} {instance}')
- instance.save()
-
- if self.action == ChangeActionChoices.ACTION_UPDATE:
- instance = deserialize_object(self.model, self.data, pk=self.object_id)
- logger.info(f'Updating {self.model._meta.verbose_name} {instance}')
- instance.save()
-
- if self.action == ChangeActionChoices.ACTION_DELETE:
- instance = self.model.objects.get(pk=self.object_id)
- logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
- instance.delete()
-
- # Rebuild the MPTT tree where applicable
- if issubclass(self.model, MPTTModel):
- self.model.objects.rebuild()
-
- apply.alters_data = True
-
- def get_action_color(self):
- return ChangeActionChoices.colors.get(self.action)
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index d1e329f03..4c6396172 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -9,6 +9,7 @@ from netbox.choices import ColorChoices
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.fields import ColorField
+from utilities.querysets import RestrictedQuerySet
__all__ = (
'Tag',
@@ -39,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')
@@ -72,6 +77,7 @@ class TaggedItem(GenericTaggedItemBase):
)
_netbox_private = True
+ objects = RestrictedQuerySet.as_manager()
class Meta:
indexes = [models.Index(fields=["content_type", "object_id"])]
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index f2bd75a1d..83195402d 100644
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -2,10 +2,12 @@ import inspect
import json
import logging
import os
+import re
import yaml
from django import forms
from django.conf import settings
+from django.core.files.storage import storages
from django.core.validators import RegexValidator
from django.utils import timezone
from django.utils.functional import classproperty
@@ -367,9 +369,46 @@ class BaseScript:
def filename(self):
return inspect.getfile(self.__class__)
+ def findsource(self, object):
+ storage = storages.create_storage(storages.backends["scripts"])
+ with storage.open(os.path.basename(self.filename), 'r') as f:
+ data = f.read()
+
+ # Break the source code into lines
+ lines = [line + '\n' for line in data.splitlines()]
+
+ # Find the class definition
+ name = object.__name__
+ pat = re.compile(r'^(\s*)class\s*' + name + r'\b')
+ # use the class definition with the least indentation
+ candidates = []
+ for i in range(len(lines)):
+ match = pat.match(lines[i])
+ if match:
+ if lines[i][0] == 'c':
+ return lines, i
+
+ candidates.append((match.group(1), i))
+ if not candidates:
+ raise OSError('could not find class definition')
+
+ # Sort the candidates by whitespace, and by line number
+ candidates.sort()
+ return lines, candidates[0][1]
+
@property
def source(self):
- return inspect.getsource(self.__class__)
+ # Can't use inspect.getsource() as it uses os to get the file
+ # inspect uses ast, but that is overkill for this as we only do
+ # classes.
+ object = self.__class__
+
+ try:
+ lines, lnum = self.findsource(object)
+ lines = inspect.getblock(lines[lnum:])
+ return ''.join(lines)
+ except OSError:
+ return ''
@classmethod
def _get_vars(cls):
@@ -524,7 +563,12 @@ class BaseScript:
def load_yaml(self, filename):
"""
Return data from a YAML file
+ TODO: DEPRECATED: Remove this method in v4.4
"""
+ self._log(
+ _("load_yaml is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
try:
from yaml import CLoader as Loader
except ImportError:
@@ -539,7 +583,12 @@ class BaseScript:
def load_json(self, filename):
"""
Return data from a JSON file
+ TODO: DEPRECATED: Remove this method in v4.4
"""
+ self._log(
+ _("load_json is deprecated and will be removed in v4.4"),
+ level=LogLevelChoices.LOG_WARNING
+ )
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
@@ -555,7 +604,6 @@ class BaseScript:
Run the report and save its results. Each test method will be executed in order.
"""
self.logger.info("Running report")
-
try:
for test_name in self.tests:
self._current_test = test_name
diff --git a/netbox/extras/storage.py b/netbox/extras/storage.py
new file mode 100644
index 000000000..ede4fac7f
--- /dev/null
+++ b/netbox/extras/storage.py
@@ -0,0 +1,14 @@
+from django.conf import settings
+from django.core.files.storage import FileSystemStorage
+from django.utils.functional import cached_property
+
+
+class ScriptFileSystemStorage(FileSystemStorage):
+ """
+ Custom storage for scripts - for django-storages as the default one will
+ go off media-root and raise security errors as the scripts can be outside
+ the media-root directory.
+ """
+ @cached_property
+ def base_location(self):
+ return settings.SCRIPTS_ROOT
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index e538c488e..60b207058 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -183,6 +183,15 @@ class ExportTemplateTable(NetBoxTable):
object_types = columns.ContentTypesColumn(
verbose_name=_('Object Types'),
)
+ mime_type = tables.Column(
+ verbose_name=_('MIME Type')
+ )
+ file_name = tables.Column(
+ verbose_name=_('File Name'),
+ )
+ file_extension = tables.Column(
+ verbose_name=_('File Extension'),
+ )
as_attachment = columns.BooleanColumn(
verbose_name=_('As Attachment'),
false_mark=None
@@ -203,11 +212,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 +459,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')
@@ -526,6 +536,19 @@ class ConfigTemplateTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
+ mime_type = tables.Column(
+ verbose_name=_('MIME Type')
+ )
+ file_name = tables.Column(
+ verbose_name=_('File Name'),
+ )
+ file_extension = tables.Column(
+ verbose_name=_('File Extension'),
+ )
+ as_attachment = columns.BooleanColumn(
+ verbose_name=_('As Attachment'),
+ false_mark=None
+ )
tags = columns.TagColumn(
url_name='extras:configtemplate_list'
)
@@ -553,8 +576,9 @@ class ConfigTemplateTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ConfigTemplate
fields = (
- 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
- 'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
+ 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'as_attachment',
+ 'mime_type', 'file_name', 'file_extension', 'role_count', 'platform_count', 'device_count',
+ 'vm_count', 'created', 'last_updated', 'tags',
)
default_columns = (
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 63baf44d3..6e3fb37fc 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -479,6 +479,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
'object_types': ['dcim.device'],
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ 'file_name': 'test_export_template_6',
},
]
bulk_update_data = {
@@ -494,7 +495,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
ExportTemplate(
name='Export Template 2',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ file_name='export_template_2',
+ file_extension='test',
),
ExportTemplate(
name='Export Template 3',
@@ -502,8 +505,10 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
),
)
ExportTemplate.objects.bulk_create(export_templates)
+
+ device_object_type = ObjectType.objects.get_for_model(Device)
for et in export_templates:
- et.object_types.set([ObjectType.objects.get_for_model(Device)])
+ et.object_types.set([device_object_type])
class TagTest(APIViewTestCases.APIViewTestCase):
@@ -513,6 +518,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Tag 4',
'slug': 'tag-4',
+ 'weight': 1000,
},
{
'name': 'Tag 5',
@@ -527,6 +533,24 @@ class TagTest(APIViewTestCases.APIViewTestCase):
'description': 'New description',
}
+ @classmethod
+ def setUpTestData(cls):
+
+ tags = (
+ Tag(name='Tag 1', slug='tag-1'),
+ Tag(name='Tag 2', slug='tag-2'),
+ Tag(name='Tag 3', slug='tag-3', weight=26),
+ )
+ Tag.objects.bulk_create(tags)
+
+
+class TaggedItemTest(
+ APIViewTestCases.GetObjectViewTestCase,
+ APIViewTestCases.ListObjectsViewTestCase
+):
+ model = TaggedItem
+ brief_fields = ['display', 'id', 'object', 'object_id', 'object_type', 'tag', 'url']
+
@classmethod
def setUpTestData(cls):
@@ -537,6 +561,16 @@ class TagTest(APIViewTestCases.APIViewTestCase):
)
Tag.objects.bulk_create(tags)
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+ sites[0].tags.set([tags[0], tags[1]])
+ sites[1].tags.set([tags[1], tags[2]])
+ sites[2].tags.set([tags[2], tags[0]])
+
# TODO: Standardize to APIViewTestCase (needs create & update tests)
class ImageAttachmentTest(
@@ -721,6 +755,10 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
{
'name': 'Config Template 4',
'template_code': 'Foo: {{ foo }}',
+ 'mime_type': 'text/plain',
+ 'file_name': 'output4',
+ 'file_extension': 'txt',
+ 'as_attachment': True,
},
{
'name': 'Config Template 5',
@@ -744,7 +782,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
),
ConfigTemplate(
name='Config Template 2',
- template_code='Bar: {{ bar }}'
+ template_code='Bar: {{ bar }}',
),
ConfigTemplate(
name='Config Template 3',
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index cf914e665..987dfe0ff 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -616,16 +616,39 @@ class BookmarkTestCase(TestCase, BaseFilterSetTests):
class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilterSet
- ignore_fields = ('template_code', 'data_path')
+ ignore_fields = ('template_code', 'environment_params', 'data_path')
@classmethod
def setUpTestData(cls):
object_types = ObjectType.objects.filter(model__in=['site', 'rack', 'device'])
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 1',
+ template_code='TESTING',
+ description='foobar1',
+ mime_type='text/foo',
+ file_name='foo',
+ file_extension='foo',
+ as_attachment=True,
+ ),
+ ExportTemplate(
+ name='Export Template 2',
+ template_code='TESTING',
+ description='foobar2',
+ mime_type='text/bar',
+ file_name='bar',
+ file_extension='bar',
+ as_attachment=True,
+ ),
+ ExportTemplate(
+ name='Export Template 3',
+ template_code='TESTING',
+ mime_type='text/baz',
+ file_name='baz',
+ file_extension='baz',
+ as_attachment=False,
+ ),
)
ExportTemplate.objects.bulk_create(export_templates)
for i, et in enumerate(export_templates):
@@ -649,6 +672,22 @@ class ExportTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_mime_type(self):
+ params = {'mime_type': ['text/foo', 'text/bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_name(self):
+ params = {'file_name': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_extension(self):
+ params = {'file_extension': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_as_attachment(self):
+ params = {'as_attachment': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ImageAttachmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ImageAttachment.objects.all()
@@ -884,7 +923,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
- DeviceRole.objects.bulk_create(device_roles)
+ for device_role in device_roles:
+ device_role.save()
platforms = (
Platform(name='Platform 1', slug='platform-1'),
@@ -1067,9 +1107,32 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
@classmethod
def setUpTestData(cls):
config_templates = (
- ConfigTemplate(name='Config Template 1', template_code='TESTING', description='foobar1'),
- ConfigTemplate(name='Config Template 2', template_code='TESTING', description='foobar2'),
- ConfigTemplate(name='Config Template 3', template_code='TESTING'),
+ ConfigTemplate(
+ name='Config Template 1',
+ template_code='TESTING',
+ description='foobar1',
+ mime_type='text/foo',
+ file_name='foo',
+ file_extension='foo',
+ as_attachment=True,
+ ),
+ ConfigTemplate(
+ name='Config Template 2',
+ template_code='TESTING',
+ description='foobar2',
+ mime_type='text/bar',
+ file_name='bar',
+ file_extension='bar',
+ as_attachment=True,
+ ),
+ ConfigTemplate(
+ name='Config Template 3',
+ template_code='TESTING',
+ mime_type='text/baz',
+ file_name='baz',
+ file_extension='baz',
+ as_attachment=False,
+ ),
)
ConfigTemplate.objects.bulk_create(config_templates)
@@ -1085,6 +1148,22 @@ class ConfigTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_mime_type(self):
+ params = {'mime_type': ['text/foo', 'text/bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_name(self):
+ params = {'file_name': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_file_extension(self):
+ params = {'file_extension': ['foo', 'bar']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_as_attachment(self):
+ params = {'as_attachment': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all()
@@ -1140,6 +1219,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'module',
'modulebay',
'moduletype',
+ 'moduletypeprofile',
'platform',
'powerfeed',
'poweroutlet',
@@ -1196,7 +1276,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 +1329,69 @@ 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()
+ filterset = TaggedItemFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ tags = (
+ Tag(name='Tag 1', slug='tag-1'),
+ Tag(name='Tag 2', slug='tag-2'),
+ Tag(name='Tag 3', slug='tag-3'),
+ )
+ Tag.objects.bulk_create(tags)
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+ sites[0].tags.add(tags[0])
+ sites[1].tags.add(tags[1])
+ sites[2].tags.add(tags[2])
+
+ tenants = (
+ Tenant(name='Tenant 1', slug='tenant-1'),
+ Tenant(name='Tenant 2', slug='tenant-2'),
+ Tenant(name='Tenant 3', slug='tenant-3'),
+ )
+ Tenant.objects.bulk_create(tenants)
+ tenants[0].tags.add(tags[0])
+ tenants[1].tags.add(tags[1])
+ tenants[2].tags.add(tags[2])
+
+ def test_tag(self):
+ tags = Tag.objects.all()[:2]
+ params = {'tag': [tags[0].slug, tags[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'tag_id': [tags[0].pk, tags[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_object_type(self):
+ object_type = ObjectType.objects.get_for_model(Site)
+ params = {'object_type': 'dcim.site'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'object_type_id': [object_type.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_object_id(self):
+ site_ids = Site.objects.values_list('pk', flat=True)
+ params = {
+ 'object_type': 'dcim.site',
+ 'object_id': site_ids[:2],
+ }
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ChangeLoggedFilterSetTestCase(TestCase):
"""
diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py
index c90390dd1..bf05a8c18 100644
--- a/netbox/extras/tests/test_models.py
+++ b/netbox/extras/tests/test_models.py
@@ -10,6 +10,40 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMac
class TagTest(TestCase):
+ def test_default_ordering_weight_then_name_is_set(self):
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=100)
+ Tag.objects.create(name='Tag 2', slug='tag-2')
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=10)
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=10)
+
+ tags = Tag.objects.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
+ def test_tag_related_manager_ordering_weight_then_name(self):
+ tags = [
+ Tag.objects.create(name='Tag 1', slug='tag-1', weight=100),
+ Tag.objects.create(name='Tag 2', slug='tag-2'),
+ Tag.objects.create(name='Tag 3', slug='tag-3', weight=10),
+ Tag.objects.create(name='Tag 4', slug='tag-4', weight=10),
+ ]
+
+ site = Site.objects.create(name='Site 1')
+ for tag in tags:
+ site.tags.add(tag)
+ site.save()
+
+ site = Site.objects.first()
+ tags = site.tags.all()
+
+ self.assertEqual(tags[0].slug, 'tag-2')
+ self.assertEqual(tags[1].slug, 'tag-3')
+ self.assertEqual(tags[2].slug, 'tag-4')
+ self.assertEqual(tags[3].slug, 'tag-1')
+
def test_create_tag_unicode(self):
tag = Tag(name='Testing Unicode: 台灣')
tag.save()
diff --git a/netbox/extras/tests/test_utils.py b/netbox/extras/tests/test_utils.py
new file mode 100644
index 000000000..b851acab8
--- /dev/null
+++ b/netbox/extras/tests/test_utils.py
@@ -0,0 +1,19 @@
+from django.test import TestCase
+
+from extras.models import ExportTemplate
+from extras.utils import filename_from_model
+from tenancy.models import ContactGroup, TenantGroup
+from wireless.models import WirelessLANGroup
+
+
+class FilenameFromModelTests(TestCase):
+ def test_expected_output(self):
+ cases = (
+ (ExportTemplate, 'netbox_export_templates'),
+ (ContactGroup, 'netbox_contact_groups'),
+ (TenantGroup, 'netbox_tenant_groups'),
+ (WirelessLANGroup, 'netbox_wireless_lan_groups'),
+ )
+
+ for model, expected in cases:
+ self.assertEqual(filename_from_model(model), expected)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 5d82fae4c..6378b29b8 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -301,11 +301,14 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_type = ObjectType.objects.get_for_model(Site)
TEMPLATE_CODE = """{% for object in queryset %}{{ object }}{% endfor %}"""
+ ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
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 2', template_code=TEMPLATE_CODE, environment_params={"trim_blocks": True}
+ ),
+ 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 +318,15 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Export Template X',
'object_types': [site_type.pk],
'template_code': TEMPLATE_CODE,
+ 'environment_params': ENVIRONMENT_PARAMS,
+ '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 +446,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 +456,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 = (
@@ -535,11 +541,23 @@ class ConfigTemplateTestCase(
@classmethod
def setUpTestData(cls):
TEMPLATE_CODE = """Foo: {{ foo }}"""
+ ENVIRONMENT_PARAMS = """{"trim_blocks": true}"""
config_templates = (
- ConfigTemplate(name='Config Template 1', template_code=TEMPLATE_CODE),
- ConfigTemplate(name='Config Template 2', template_code=TEMPLATE_CODE),
- ConfigTemplate(name='Config Template 3', template_code=TEMPLATE_CODE),
+ ConfigTemplate(
+ name='Config Template 1',
+ template_code=TEMPLATE_CODE)
+ ,
+ ConfigTemplate(
+ name='Config Template 2',
+ template_code=TEMPLATE_CODE,
+ environment_params={"trim_blocks": True},
+ ),
+ ConfigTemplate(
+ name='Config Template 3',
+ template_code=TEMPLATE_CODE,
+ file_name='config_template_3',
+ ),
)
ConfigTemplate.objects.bulk_create(config_templates)
@@ -547,6 +565,8 @@ class ConfigTemplateTestCase(
'name': 'Config Template X',
'description': 'Config template',
'template_code': TEMPLATE_CODE,
+ 'environment_params': ENVIRONMENT_PARAMS,
+ 'file_name': 'config_x',
}
cls.csv_update_data = (
@@ -558,6 +578,10 @@ class ConfigTemplateTestCase(
cls.bulk_edit_data = {
'description': 'New description',
+ 'mime_type': 'text/html',
+ 'file_name': 'output',
+ 'file_extension': 'html',
+ 'as_attachment': True,
}
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index efe7ada5b..155597c30 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -1,6 +1,7 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
+from django.db import models
from taggit.managers import _TaggableManager
from netbox.context import current_request
@@ -15,6 +16,23 @@ __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 filename_from_object(context: dict) -> str:
+ """Standardises how we generate filenames from model class for exports"""
+ if 'device' in context:
+ base = f"{context['device'].name or 'config'}"
+ elif 'virtualmachine' in context:
+ base = f"{context['virtualmachine'].name or 'config'}"
+ else:
+ base = 'config'
+ return base
+
+
def is_taggable(obj):
"""
Return True if the instance can have Tags assigned to it; False otherwise.
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 9cb9dd54a..2833cec0d 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -12,7 +12,6 @@ from django.utils.translation import gettext as _
from django.views.generic import View
from core.choices import ManagedFileRootPathChoices
-from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
@@ -1163,7 +1162,7 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
@register_model_view(ScriptModule, 'edit')
class ScriptModuleCreateView(generic.ObjectEditView):
queryset = ScriptModule.objects.all()
- form = ManagedFileForm
+ form = forms.ScriptFileForm
def alter_object(self, obj, *args, **kwargs):
obj.file_root = ManagedFileRootPathChoices.SCRIPTS
diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py
index 889c97ac2..368075217 100644
--- a/netbox/extras/webhooks.py
+++ b/netbox/extras/webhooks.py
@@ -3,10 +3,10 @@ import hmac
import logging
import requests
-from django.conf import settings
from django_rq import job
from jinja2.exceptions import TemplateError
+from utilities.proxy import resolve_proxies
from .constants import WEBHOOK_EVENT_TYPES
logger = logging.getLogger('netbox.webhooks')
@@ -63,9 +63,10 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
raise e
# Prepare the HTTP request
+ url = webhook.render_payload_url(context)
params = {
'method': webhook.http_method,
- 'url': webhook.render_payload_url(context),
+ 'url': url,
'headers': headers,
'data': body.encode('utf8'),
}
@@ -88,7 +89,8 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
session.verify = webhook.ssl_verification
if webhook.ca_file_path:
session.verify = webhook.ca_file_path
- response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
+ proxies = resolve_proxies(url=url, context={'client': webhook})
+ response = session.send(prepared_request, proxies=proxies)
if 200 <= response.status_code <= 299:
logger.info(f"Request succeeded; response status {response.status_code}")
diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py
index bfc7ac546..6f815b5ce 100644
--- a/netbox/ipam/api/serializers_/ip.py
+++ b/netbox/ipam/api/serializers_/ip.py
@@ -147,7 +147,8 @@ class IPRangeSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address', 'size', 'vrf', 'tenant',
'status', 'role', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
- 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'mark_populated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created',
+ 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py
index 9b5501dc5..a6f428343 100644
--- a/netbox/ipam/api/serializers_/vlans.py
+++ b/netbox/ipam/api/serializers_/vlans.py
@@ -37,6 +37,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
scope = serializers.SerializerMethodField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True)
+ tenant = TenantSerializer(nested=True, required=False, allow_null=True)
# Related object counts
vlan_count = RelatedObjectCountField('vlans')
@@ -45,7 +46,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
model = VLANGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
- 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
+ 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = []
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 12a36fbe1..ba8b45e14 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -478,7 +478,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class Meta:
model = IPRange
- fields = ('id', 'mark_utilized', 'size', 'description')
+ fields = ('id', 'mark_populated', 'mark_utilized', 'size', 'description')
def search(self, queryset, name, value):
if not value.strip():
@@ -867,7 +867,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet):
)
-class VLANGroupFilterSet(OrganizationalModelFilterSet):
+class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
scope_type = ContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'
diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py
index 2a071da8c..411208eeb 100644
--- a/netbox/ipam/forms/bulk_edit.py
+++ b/netbox/ipam/forms/bulk_edit.py
@@ -296,6 +296,11 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
queryset=Role.objects.all(),
required=False
)
+ mark_populated = forms.NullBooleanField(
+ required=False,
+ widget=BulkEditNullBooleanSelect(),
+ label=_('Treat as populated')
+ )
mark_utilized = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
@@ -435,11 +440,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
label=_('VLAN ID ranges'),
required=False
)
+ tenant = DynamicModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False
+ )
model = VLANGroup
fieldsets = (
FieldSet('site', 'vid_ranges', 'description'),
FieldSet('scope_type', 'scope', name=_('Scope')),
+ FieldSet('tenant', name=_('Tenancy')),
)
nullable_fields = ('description', 'scope')
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
index 65a6e5601..8cc034696 100644
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -268,8 +268,8 @@ class IPRangeImportForm(NetBoxModelImportForm):
class Meta:
model = IPRange
fields = (
- 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_utilized', 'description',
- 'comments', 'tags',
+ 'start_address', 'end_address', 'vrf', 'tenant', 'status', 'role', 'mark_populated', 'mark_utilized',
+ 'description', 'comments', 'tags',
)
@@ -445,10 +445,17 @@ class VLANGroupImportForm(NetBoxModelImportForm):
vid_ranges = NumericRangeArrayField(
required=False
)
+ tenant = CSVModelChoiceField(
+ label=_('Tenant'),
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text=_('Assigned tenant')
+ )
class Meta:
model = VLANGroup
- fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
+ fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'tags')
labels = {
'scope_id': 'Scope ID',
}
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index e6aef3054..f8184a0fb 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/netbox/ipam/forms/filtersets.py
@@ -266,7 +266,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = IPRange
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
- FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
+ FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_populated', 'mark_utilized', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
family = forms.ChoiceField(
@@ -291,6 +291,13 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
null_option='None',
label=_('Role')
)
+ mark_populated = forms.NullBooleanField(
+ required=False,
+ label=_('Treat as populated'),
+ widget=forms.Select(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
mark_utilized = forms.NullBooleanField(
required=False,
label=_('Treat as fully utilized'),
@@ -416,12 +423,13 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
-class VLANGroupFilterForm(NetBoxModelFilterSetForm):
+class VLANGroupFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('contains_vid', name=_('VLANs')),
+ FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index d7c6b17df..5ce3e9851 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -257,8 +257,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
fieldsets = (
FieldSet(
- 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_utilized', 'description', 'tags',
- name=_('IP Range')
+ 'vrf', 'start_address', 'end_address', 'role', 'status', 'mark_populated', 'mark_utilized', 'description',
+ 'tags', name=_('IP Range')
),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
@@ -266,8 +266,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
class Meta:
model = IPRange
fields = [
- 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_utilized',
- 'description', 'comments', 'tags',
+ 'vrf', 'start_address', 'end_address', 'status', 'role', 'tenant_group', 'tenant', 'mark_populated',
+ 'mark_utilized', 'description', 'comments', 'tags',
]
@@ -616,7 +616,7 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
return group
-class VLANGroupForm(NetBoxModelForm):
+class VLANGroupForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
@@ -639,12 +639,13 @@ class VLANGroupForm(NetBoxModelForm):
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet('scope_type', 'scope', name=_('Scope')),
+ FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class Meta:
model = VLANGroup
fields = [
- 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tags',
+ 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'tenant_group', 'tenant', 'tags',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/ipam/graphql/enums.py b/netbox/ipam/graphql/enums.py
new file mode 100644
index 000000000..34fb1a6fd
--- /dev/null
+++ b/netbox/ipam/graphql/enums.py
@@ -0,0 +1,27 @@
+import strawberry
+
+from ipam.choices import *
+
+__all__ = (
+ 'FHRPGroupAuthTypeEnum',
+ 'FHRPGroupProtocolEnum',
+ 'IPAddressFamilyEnum',
+ 'IPAddressRoleEnum',
+ 'IPAddressStatusEnum',
+ 'IPRangeStatusEnum',
+ 'PrefixStatusEnum',
+ 'ServiceProtocolEnum',
+ 'VLANStatusEnum',
+ 'VLANQinQRoleEnum',
+)
+
+FHRPGroupAuthTypeEnum = strawberry.enum(FHRPGroupAuthTypeChoices.as_enum())
+FHRPGroupProtocolEnum = strawberry.enum(FHRPGroupProtocolChoices.as_enum())
+IPAddressFamilyEnum = strawberry.enum(IPAddressFamilyChoices.as_enum())
+IPAddressRoleEnum = strawberry.enum(IPAddressRoleChoices.as_enum())
+IPAddressStatusEnum = strawberry.enum(IPAddressStatusChoices.as_enum())
+IPRangeStatusEnum = strawberry.enum(IPRangeStatusChoices.as_enum())
+PrefixStatusEnum = strawberry.enum(PrefixStatusChoices.as_enum())
+ServiceProtocolEnum = strawberry.enum(ServiceProtocolChoices.as_enum())
+VLANStatusEnum = strawberry.enum(VLANStatusChoices.as_enum())
+VLANQinQRoleEnum = strawberry.enum(VLANQinQRoleChoices.as_enum())
diff --git a/netbox/ipam/graphql/filter_mixins.py b/netbox/ipam/graphql/filter_mixins.py
new file mode 100644
index 000000000..511850285
--- /dev/null
+++ b/netbox/ipam/graphql/filter_mixins.py
@@ -0,0 +1,25 @@
+from dataclasses import dataclass
+from typing import Annotated, TYPE_CHECKING
+
+import strawberry
+import strawberry_django
+
+from core.graphql.filter_mixins import BaseFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerLookup
+ from .enums import *
+
+__all__ = (
+ 'ServiceBaseFilterMixin',
+)
+
+
+@dataclass
+class ServiceBaseFilterMixin(BaseFilterMixin):
+ protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py
index 1b0e0133b..2f4e185f1 100644
--- a/netbox/ipam/graphql/filters.py
+++ b/netbox/ipam/graphql/filters.py
@@ -1,7 +1,28 @@
-import strawberry_django
+from datetime import date
+from typing import Annotated, TYPE_CHECKING
-from ipam import filtersets, models
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+import netaddr
+import strawberry
+import strawberry_django
+from django.db.models import Q
+from netaddr.core import AddrFormatError
+from strawberry.scalars import ID
+from strawberry_django import FilterLookup, DateFilterLookup
+
+from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from dcim.graphql.filter_mixins import ScopedFilterMixin
+from ipam import models
+from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
+from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
+from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
+
+if TYPE_CHECKING:
+ from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
+ from core.graphql.filters import ContentTypeFilter
+ from dcim.graphql.filters import DeviceFilter, SiteFilter
+ from virtualization.graphql.filters import VirtualMachineFilter
+ from vpn.graphql.filters import L2VPNFilter
+ from .enums import *
__all__ = (
'ASNFilter',
@@ -26,108 +47,258 @@ __all__ = (
@strawberry_django.filter(models.ASN, lookups=True)
-@autotype_decorator(filtersets.ASNFilterSet)
-class ASNFilter(BaseFilterMixin):
- pass
+class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ASNRange, lookups=True)
-@autotype_decorator(filtersets.ASNRangeFilterSet)
-class ASNRangeFilter(BaseFilterMixin):
- pass
+class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ start: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ end: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.Aggregate, lookups=True)
-@autotype_decorator(filtersets.AggregateFilterSet)
-class AggregateFilter(BaseFilterMixin):
- pass
+class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ prefix: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ prefix_id: ID | None = strawberry_django.filter_field()
+ rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ rir_id: ID | None = strawberry_django.filter_field()
+ date_added: DateFilterLookup[date] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.FHRPGroup, lookups=True)
-@autotype_decorator(filtersets.FHRPGroupFilterSet)
-class FHRPGroupFilter(BaseFilterMixin):
- pass
+class FHRPGroupFilter(PrimaryModelFilterMixin):
+ group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ protocol: Annotated['FHRPGroupProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ auth_type: Annotated['FHRPGroupAuthTypeEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ auth_key: FilterLookup[str] | None = strawberry_django.filter_field()
+ ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True)
-@autotype_decorator(filtersets.FHRPGroupAssignmentFilterSet)
-class FHRPGroupAssignmentFilter(BaseFilterMixin):
- pass
+class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+ interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ interface_id: FilterLookup[str] | None = strawberry_django.filter_field()
+ group: Annotated['FHRPGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: ID | None = strawberry_django.filter_field()
+ priority: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.IPAddress, lookups=True)
-@autotype_decorator(filtersets.IPAddressFilterSet)
-class IPAddressFilter(BaseFilterMixin):
- pass
+class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ address: FilterLookup[str] | None = strawberry_django.filter_field()
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['IPAddressStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ assigned_object_id: ID | None = strawberry_django.filter_field()
+ nat_inside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ nat_inside_id: ID | None = strawberry_django.filter_field()
+ nat_outside: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ nat_outside_id: ID | None = strawberry_django.filter_field()
+ dns_name: FilterLookup[str] | None = strawberry_django.filter_field()
+
+ @strawberry_django.filter_field()
+ def parent(self, value: list[str], prefix) -> Q:
+ if not value:
+ return Q()
+ q = Q()
+ for subnet in value:
+ try:
+ query = str(netaddr.IPNetwork(subnet.strip()).cidr)
+ q |= Q(address__net_host_contained=query)
+ except (AddrFormatError, ValueError):
+ return Q()
+ return q
@strawberry_django.filter(models.IPRange, lookups=True)
-@autotype_decorator(filtersets.IPRangeFilterSet)
-class IPRangeFilter(BaseFilterMixin):
- pass
+class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ start_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ end_address: FilterLookup[str] | None = strawberry_django.filter_field()
+ size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['IPRangeStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['IPAddressRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+ @strawberry_django.filter_field()
+ def parent(self, value: list[str], prefix) -> Q:
+ if not value:
+ return Q()
+ q = Q()
+ for subnet in value:
+ try:
+ query = str(netaddr.IPNetwork(subnet.strip()).cidr)
+ q |= Q(start_address__net_host_contained=query, end_address__net_host_contained=query)
+ except (AddrFormatError, ValueError):
+ return Q()
+ return q
@strawberry_django.filter(models.Prefix, lookups=True)
-@autotype_decorator(filtersets.PrefixFilterSet)
-class PrefixFilter(BaseFilterMixin):
- pass
+class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+ prefix: FilterLookup[str] | None = strawberry_django.filter_field()
+ vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vrf_id: ID | None = strawberry_django.filter_field()
+ vlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ vlan_id: ID | None = strawberry_django.filter_field()
+ status: Annotated['PrefixStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ role_id: ID | None = strawberry_django.filter_field()
+ is_pool: FilterLookup[bool] | None = strawberry_django.filter_field()
+ mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.RIR, lookups=True)
-@autotype_decorator(filtersets.RIRFilterSet)
-class RIRFilter(BaseFilterMixin):
- pass
+class RIRFilter(OrganizationalModelFilterMixin):
+ is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Role, lookups=True)
-@autotype_decorator(filtersets.RoleFilterSet)
-class RoleFilter(BaseFilterMixin):
- pass
+class RoleFilter(OrganizationalModelFilterMixin):
+ weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.RouteTarget, lookups=True)
-@autotype_decorator(filtersets.RouteTargetFilterSet)
-class RouteTargetFilter(BaseFilterMixin):
- pass
+class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.Service, lookups=True)
-@autotype_decorator(filtersets.ServiceFilterSet)
-class ServiceFilter(BaseFilterMixin):
- pass
+class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+ device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ device_id: ID | None = strawberry_django.filter_field()
+ virtual_machine: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ virtual_machine_id: ID | None = strawberry_django.filter_field()
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ ipaddresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.ServiceTemplate, lookups=True)
-@autotype_decorator(filtersets.ServiceTemplateFilterSet)
-class ServiceTemplateFilter(BaseFilterMixin):
- pass
+class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLAN, lookups=True)
-@autotype_decorator(filtersets.VLANFilterSet)
-class VLANFilter(BaseFilterMixin):
- pass
+class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+ site_id: ID | None = strawberry_django.filter_field()
+ group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ group_id: ID | None = strawberry_django.filter_field()
+ vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ status: Annotated['VLANStatusEnum', strawberry.lazy('ipam.graphql.enums')] | None = strawberry_django.filter_field()
+ role: Annotated['RoleFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
+ role_id: ID | None = strawberry_django.filter_field()
+ qinq_svlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ qinq_svlan_id: ID | None = strawberry_django.filter_field()
+ qinq_cvlan: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ qinq_cvlan_id: ID | None = strawberry_django.filter_field()
+ qinq_role: Annotated['VLANQinQRoleEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+ l2vpn_terminations: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VLANGroup, lookups=True)
-@autotype_decorator(filtersets.VLANGroupFilterSet)
-class VLANGroupFilter(BaseFilterMixin):
- pass
+class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
+ vid_ranges: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VLANTranslationPolicy, lookups=True)
-@autotype_decorator(filtersets.VLANTranslationPolicyFilterSet)
-class VLANTranslationPolicyFilter(BaseFilterMixin):
- pass
+class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.VLANTranslationRule, lookups=True)
-@autotype_decorator(filtersets.VLANTranslationRuleFilterSet)
-class VLANTranslationRuleFilter(BaseFilterMixin):
- pass
+class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
+ policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ policy_id: ID | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ local_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
+ remote_vid: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+ strawberry_django.filter_field()
+ )
@strawberry_django.filter(models.VRF, lookups=True)
-@autotype_decorator(filtersets.VRFFilterSet)
-class VRFFilter(BaseFilterMixin):
- pass
+class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ rd: FilterLookup[str] | None = strawberry_django.filter_field()
+ enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
+ import_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ export_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
index 446ac2f2e..5a4c87dbe 100644
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -1,4 +1,4 @@
-from typing import Annotated, List, Union
+from typing import Annotated, List, TYPE_CHECKING, Union
import strawberry
import strawberry_django
@@ -11,6 +11,21 @@ from netbox.graphql.types import BaseObjectType, NetBoxObjectType, Organizationa
from .filters import *
from .mixins import IPAddressesMixin
+if TYPE_CHECKING:
+ from dcim.graphql.types import (
+ DeviceType,
+ InterfaceType,
+ LocationType,
+ RackType,
+ RegionType,
+ SiteGroupType,
+ SiteType,
+ )
+ from tenancy.graphql.types import TenantType
+ from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType, VirtualMachineType
+ from vpn.graphql.types import L2VPNType, TunnelTerminationType
+ from wireless.graphql.types import WirelessLANType
+
__all__ = (
'ASNType',
'ASNRangeType',
@@ -55,7 +70,8 @@ class BaseIPAddressFamilyType:
@strawberry_django.type(
models.ASN,
fields='__all__',
- filters=ASNFilter
+ filters=ASNFilter,
+ pagination=True
)
class ASNType(NetBoxObjectType):
asn: BigInt
@@ -69,7 +85,8 @@ class ASNType(NetBoxObjectType):
@strawberry_django.type(
models.ASNRange,
fields='__all__',
- filters=ASNRangeFilter
+ filters=ASNRangeFilter,
+ pagination=True
)
class ASNRangeType(NetBoxObjectType):
start: BigInt
@@ -81,7 +98,8 @@ class ASNRangeType(NetBoxObjectType):
@strawberry_django.type(
models.Aggregate,
fields='__all__',
- filters=AggregateFilter
+ filters=AggregateFilter,
+ pagination=True
)
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
prefix: str
@@ -92,7 +110,8 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
@strawberry_django.type(
models.FHRPGroup,
fields='__all__',
- filters=FHRPGroupFilter
+ filters=FHRPGroupFilter,
+ pagination=True
)
class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
@@ -101,8 +120,9 @@ class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
@strawberry_django.type(
models.FHRPGroupAssignment,
- exclude=('interface_type', 'interface_id'),
- filters=FHRPGroupAssignmentFilter
+ exclude=['interface_type', 'interface_id'],
+ filters=FHRPGroupAssignmentFilter,
+ pagination=True
)
class FHRPGroupAssignmentType(BaseObjectType):
group: Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')]
@@ -117,8 +137,9 @@ class FHRPGroupAssignmentType(BaseObjectType):
@strawberry_django.type(
models.IPAddress,
- exclude=('assigned_object_type', 'assigned_object_id', 'address'),
- filters=IPAddressFilter
+ exclude=['assigned_object_type', 'assigned_object_id', 'address'],
+ filters=IPAddressFilter,
+ pagination=True
)
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
address: str
@@ -143,7 +164,8 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
@strawberry_django.type(
models.IPRange,
fields='__all__',
- filters=IPRangeFilter
+ filters=IPRangeFilter,
+ pagination=True
)
class IPRangeType(NetBoxObjectType):
start_address: str
@@ -155,8 +177,9 @@ class IPRangeType(NetBoxObjectType):
@strawberry_django.type(
models.Prefix,
- exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
- filters=PrefixFilter
+ exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'],
+ filters=PrefixFilter,
+ pagination=True
)
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
prefix: str
@@ -178,7 +201,8 @@ class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
@strawberry_django.type(
models.RIR,
fields='__all__',
- filters=RIRFilter
+ filters=RIRFilter,
+ pagination=True
)
class RIRType(OrganizationalObjectType):
@@ -190,7 +214,8 @@ class RIRType(OrganizationalObjectType):
@strawberry_django.type(
models.Role,
fields='__all__',
- filters=RoleFilter
+ filters=RoleFilter,
+ pagination=True
)
class RoleType(OrganizationalObjectType):
@@ -202,7 +227,8 @@ class RoleType(OrganizationalObjectType):
@strawberry_django.type(
models.RouteTarget,
fields='__all__',
- filters=RouteTargetFilter
+ filters=RouteTargetFilter,
+ pagination=True
)
class RouteTargetType(NetBoxObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@@ -216,7 +242,8 @@ class RouteTargetType(NetBoxObjectType):
@strawberry_django.type(
models.Service,
fields='__all__',
- filters=ServiceFilter
+ filters=ServiceFilter,
+ pagination=True
)
class ServiceType(NetBoxObjectType):
ports: List[int]
@@ -229,7 +256,8 @@ class ServiceType(NetBoxObjectType):
@strawberry_django.type(
models.ServiceTemplate,
fields='__all__',
- filters=ServiceTemplateFilter
+ filters=ServiceTemplateFilter,
+ pagination=True
)
class ServiceTemplateType(NetBoxObjectType):
ports: List[int]
@@ -237,8 +265,9 @@ class ServiceTemplateType(NetBoxObjectType):
@strawberry_django.type(
models.VLAN,
- exclude=('qinq_svlan',),
- filters=VLANFilter
+ exclude=['qinq_svlan'],
+ filters=VLANFilter,
+ pagination=True
)
class VLANType(NetBoxObjectType):
site: Annotated["SiteType", strawberry.lazy('ipam.graphql.types')] | None
@@ -260,13 +289,15 @@ class VLANType(NetBoxObjectType):
@strawberry_django.type(
models.VLANGroup,
- exclude=('scope_type', 'scope_id'),
- filters=VLANGroupFilter
+ exclude=['scope_type', 'scope_id'],
+ filters=VLANGroupFilter,
+ pagination=True
)
class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType]
vid_ranges: List[str]
+ tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
@strawberry_django.field
def scope(self) -> Annotated[Union[
@@ -284,7 +315,8 @@ class VLANGroupType(OrganizationalObjectType):
@strawberry_django.type(
models.VLANTranslationPolicy,
fields='__all__',
- filters=VLANTranslationPolicyFilter
+ filters=VLANTranslationPolicyFilter,
+ pagination=True
)
class VLANTranslationPolicyType(NetBoxObjectType):
rules: List[Annotated["VLANTranslationRuleType", strawberry.lazy('ipam.graphql.types')]]
@@ -293,7 +325,8 @@ class VLANTranslationPolicyType(NetBoxObjectType):
@strawberry_django.type(
models.VLANTranslationRule,
fields='__all__',
- filters=VLANTranslationRuleFilter
+ filters=VLANTranslationRuleFilter,
+ pagination=True
)
class VLANTranslationRuleType(NetBoxObjectType):
policy: Annotated[
@@ -305,7 +338,8 @@ class VLANTranslationRuleType(NetBoxObjectType):
@strawberry_django.type(
models.VRF,
fields='__all__',
- filters=VRFFilter
+ filters=VRFFilter,
+ pagination=True
)
class VRFType(NetBoxObjectType):
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
diff --git a/netbox/ipam/migrations/0001_squashed.py b/netbox/ipam/migrations/0001_squashed.py
index 896d7c4c9..15fb71dde 100644
--- a/netbox/ipam/migrations/0001_squashed.py
+++ b/netbox/ipam/migrations/0001_squashed.py
@@ -13,9 +13,9 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
- ('dcim', '0002_auto_20160622_1821'),
- ('extras', '0001_initial'),
- ('tenancy', '0001_initial'),
+ ('dcim', '0002_squashed'),
+ ('extras', '0001_squashed'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
@@ -195,12 +195,6 @@ class Migration(migrations.Migration):
'scope_type',
models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- (
- 'model__in',
- ('region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'),
- )
- ),
null=True,
on_delete=django.db.models.deletion.CASCADE,
to='contenttypes.contenttype',
diff --git a/netbox/ipam/migrations/0002_squashed_0046.py b/netbox/ipam/migrations/0002_squashed_0046.py
index 6c03753d8..43b1223d0 100644
--- a/netbox/ipam/migrations/0002_squashed_0046.py
+++ b/netbox/ipam/migrations/0002_squashed_0046.py
@@ -5,12 +5,12 @@ import taggit.managers
class Migration(migrations.Migration):
dependencies = [
- ('dcim', '0003_auto_20160628_1721'),
- ('virtualization', '0001_virtualization'),
+ ('dcim', '0003_squashed_0130'),
+ ('virtualization', '0001_squashed_0022'),
('contenttypes', '0002_remove_content_type_name'),
- ('ipam', '0001_initial'),
- ('extras', '0002_custom_fields'),
- ('tenancy', '0001_initial'),
+ ('ipam', '0001_squashed'),
+ ('extras', '0002_squashed_0059'),
+ ('tenancy', '0001_squashed_0012'),
]
replaces = [
@@ -154,13 +154,6 @@ class Migration(migrations.Migration):
name='assigned_object_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0047_squashed_0053.py b/netbox/ipam/migrations/0047_squashed_0053.py
index a05d0cb81..151792eb6 100644
--- a/netbox/ipam/migrations/0047_squashed_0053.py
+++ b/netbox/ipam/migrations/0047_squashed_0053.py
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('ipam', '0046_set_vlangroup_scope_types'),
+ ('ipam', '0002_squashed_0046'),
('tenancy', '0001_squashed_0012'),
('extras', '0002_squashed_0059'),
('contenttypes', '0002_remove_content_type_name'),
@@ -136,14 +136,6 @@ class Migration(migrations.Migration):
name='assigned_object_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0054_squashed_0067.py b/netbox/ipam/migrations/0054_squashed_0067.py
index 929a27fda..26bd54115 100644
--- a/netbox/ipam/migrations/0054_squashed_0067.py
+++ b/netbox/ipam/migrations/0054_squashed_0067.py
@@ -28,11 +28,11 @@ class Migration(migrations.Migration):
]
dependencies = [
- ('tenancy', '0007_contact_link'),
+ ('tenancy', '0002_squashed_0011'),
('contenttypes', '0002_remove_content_type_name'),
('extras', '0060_squashed_0086'),
- ('ipam', '0053_asn_model'),
- ('tenancy', '0009_standardize_description_comments'),
+ ('ipam', '0047_squashed_0053'),
+ ('tenancy', '0002_squashed_0011'),
]
operations = [
@@ -304,14 +304,6 @@ class Migration(migrations.Migration):
(
'assigned_object_type',
models.ForeignKey(
- limit_choices_to=models.Q(
- models.Q(
- models.Q(('app_label', 'dcim'), ('model', 'interface')),
- models.Q(('app_label', 'ipam'), ('model', 'vlan')),
- models.Q(('app_label', 'virtualization'), ('model', 'vminterface')),
- _connector='OR',
- )
- ),
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype',
diff --git a/netbox/ipam/migrations/0068_move_l2vpn.py b/netbox/ipam/migrations/0068_move_l2vpn.py
index 9240240bc..16935b1a6 100644
--- a/netbox/ipam/migrations/0068_move_l2vpn.py
+++ b/netbox/ipam/migrations/0068_move_l2vpn.py
@@ -16,7 +16,7 @@ def update_content_types(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('ipam', '0067_ipaddress_index_host'),
+ ('ipam', '0054_squashed_0067'),
]
operations = [
diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py
index 2ab54d023..47a971750 100644
--- a/netbox/ipam/migrations/0071_prefix_scope.py
+++ b/netbox/ipam/migrations/0071_prefix_scope.py
@@ -33,7 +33,6 @@ class Migration(migrations.Migration):
name='scope_type',
field=models.ForeignKey(
blank=True,
- limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
diff --git a/netbox/ipam/migrations/0077_vlangroup_tenant.py b/netbox/ipam/migrations/0077_vlangroup_tenant.py
new file mode 100644
index 000000000..9fb67cf53
--- /dev/null
+++ b/netbox/ipam/migrations/0077_vlangroup_tenant.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.1.3 on 2025-02-20 17:49
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0076_natural_ordering'),
+ ('tenancy', '0017_natural_ordering'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vlangroup',
+ name='tenant',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='vlan_groups',
+ to='tenancy.tenant',
+ ),
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0078_iprange_mark_utilized.py b/netbox/ipam/migrations/0078_iprange_mark_utilized.py
new file mode 100644
index 000000000..95da5387b
--- /dev/null
+++ b/netbox/ipam/migrations/0078_iprange_mark_utilized.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0077_vlangroup_tenant'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='iprange',
+ name='mark_populated',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/netbox/ipam/migrations/0077_ipaddress_prefix.py b/netbox/ipam/migrations/0079_ipaddress_prefix.py
similarity index 97%
rename from netbox/ipam/migrations/0077_ipaddress_prefix.py
rename to netbox/ipam/migrations/0079_ipaddress_prefix.py
index 216fa166a..8d3c3b67c 100644
--- a/netbox/ipam/migrations/0077_ipaddress_prefix.py
+++ b/netbox/ipam/migrations/0079_ipaddress_prefix.py
@@ -48,7 +48,7 @@ def unset_prefix(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('ipam', '0076_natural_ordering'),
+ ('ipam', '0078_iprange_mark_utilized'),
]
operations = [
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 3db12f761..f356fb8ba 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -383,14 +383,15 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
else:
return Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
- def get_child_ranges(self):
+ def get_child_ranges(self, **kwargs):
"""
Return all IPRanges within this Prefix and VRF.
"""
return IPRange.objects.filter(
vrf=self.vrf,
start_address__net_host_contained=str(self.prefix),
- end_address__net_host_contained=str(self.prefix)
+ end_address__net_host_contained=str(self.prefix),
+ **kwargs
)
def get_child_ips(self):
@@ -407,15 +408,14 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
"""
Return all available IPs within this prefix as an IPSet.
"""
- if self.mark_utilized:
- return netaddr.IPSet()
-
prefix = netaddr.IPSet(self.prefix)
- child_ips = netaddr.IPSet([ip.address.ip for ip in self.ip_addresses.all()])
- child_ranges = []
- for iprange in self.get_child_ranges():
- child_ranges.append(iprange.range)
- available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
+ child_ips = netaddr.IPSet([
+ ip.address.ip for ip in self.get_child_ips()
+ ])
+ child_ranges = netaddr.IPSet([
+ iprange.range for iprange in self.get_child_ranges().filter(mark_populated=True)
+ ])
+ available_ips = prefix - child_ips - child_ranges
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (
@@ -433,6 +433,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
# For IPv6 prefixes, omit the Subnet-Router anycast address
# per RFC 4291
available_ips -= netaddr.IPSet([netaddr.IPAddress(self.prefix.first)])
+
return available_ips
def get_first_available_ip(self):
@@ -461,9 +462,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
utilization = float(child_prefixes.size) / self.prefix.size * 100
else:
# Compile an IPSet to avoid counting duplicate IPs
- child_ips = netaddr.IPSet(
- [_.range for _ in self.get_child_ranges()] + [_.address.ip for _ in self.ip_addresses.all()]
- )
+ child_ips = netaddr.IPSet()
+ for iprange in self.get_child_ranges().filter(mark_utilized=True):
+ child_ips.add(iprange.range)
+ for ip in self.get_child_ips():
+ child_ips.add(ip.address.ip)
prefix_size = self.prefix.size
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
@@ -519,14 +522,19 @@ class IPRange(ContactsMixin, PrimaryModel):
null=True,
help_text=_('The primary function of this range')
)
+ mark_populated = models.BooleanField(
+ verbose_name=_('mark populated'),
+ default=False,
+ help_text=_("Prevent the creation of IP addresses within this range")
+ )
mark_utilized = models.BooleanField(
verbose_name=_('mark utilized'),
default=False,
- help_text=_("Treat as fully utilized")
+ help_text=_("Report space as 100% utilized")
)
clone_fields = (
- 'vrf', 'tenant', 'status', 'role', 'description',
+ 'vrf', 'tenant', 'status', 'role', 'description', 'mark_populated', 'mark_utilized',
)
class Meta:
@@ -663,6 +671,9 @@ class IPRange(ContactsMixin, PrimaryModel):
"""
Return all available IPs within this range as an IPSet.
"""
+ if self.mark_populated:
+ return netaddr.IPSet()
+
range = netaddr.IPRange(self.start_address.ip, self.end_address.ip)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
@@ -750,7 +761,6 @@ class IPAddress(ContactsMixin, PrimaryModel):
)
assigned_object_type = models.ForeignKey(
to='contenttypes.ContentType',
- limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
@@ -889,6 +899,20 @@ class IPAddress(ContactsMixin, PrimaryModel):
)
})
+ # Disallow the creation of IPAddresses within an IPRange with mark_populated=True
+ parent_range = IPRange.objects.filter(
+ start_address__lte=self.address,
+ end_address__gte=self.address,
+ vrf=self.vrf,
+ mark_populated=True
+ ).first()
+ if parent_range:
+ raise ValidationError({
+ 'address': _(
+ "Cannot create IP address {ip} inside range {range}."
+ ).format(ip=self.address, range=parent_range)
+ })
+
if self._original_assigned_object_id and self._original_assigned_object_type_id:
parent = getattr(self.assigned_object, 'parent_object', None)
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 91e39c6d3..98491ad23 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -45,7 +45,6 @@ class VLANGroup(OrganizationalModel):
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.CASCADE,
- limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES),
blank=True,
null=True
)
@@ -62,6 +61,13 @@ class VLANGroup(OrganizationalModel):
verbose_name=_('VLAN ID ranges'),
default=default_vid_ranges
)
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='vlan_groups',
+ blank=True,
+ null=True
+ )
_total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py
index 0ad984248..7818e66ad 100644
--- a/netbox/ipam/tables/ip.py
+++ b/netbox/ipam/tables/ip.py
@@ -10,6 +10,7 @@ from .template_code import *
__all__ = (
'AggregateTable',
+ 'AnnotatedIPAddressTable',
'AssignedIPAddressesTable',
'IPAddressAssignTable',
'IPAddressTable',
@@ -268,6 +269,10 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Role'),
linkify=True
)
+ mark_populated = columns.BooleanColumn(
+ verbose_name=_('Marked Populated'),
+ false_mark=None
+ )
mark_utilized = columns.BooleanColumn(
verbose_name=_('Marked Utilized'),
false_mark=None
@@ -288,7 +293,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group',
- 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created', 'last_updated',
+ 'mark_populated', 'mark_utilized', 'utilization', 'description', 'comments', 'tags', 'created',
+ 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@@ -303,8 +309,8 @@ class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
#
class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
- address = tables.TemplateColumn(
- template_code=IPADDRESS_LINK,
+ address = tables.Column(
+ linkify=True,
verbose_name=_('IP Address')
)
prefix = tables.Column(
@@ -373,6 +379,16 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
}
+class AnnotatedIPAddressTable(IPAddressTable):
+ address = tables.TemplateColumn(
+ template_code=IPADDRESS_LINK,
+ verbose_name=_('IP Address')
+ )
+
+ class Meta(IPAddressTable.Meta):
+ pass
+
+
class IPAddressAssignTable(NetBoxTable):
address = tables.TemplateColumn(
template_code=IPADDRESS_ASSIGN_LINK,
diff --git a/netbox/ipam/tables/template_code.py b/netbox/ipam/tables/template_code.py
index fb969345e..cc3a344bc 100644
--- a/netbox/ipam/tables/template_code.py
+++ b/netbox/ipam/tables/template_code.py
@@ -26,12 +26,12 @@ PREFIX_LINK_WITH_DEPTH = """
""" + PREFIX_LINK
IPADDRESS_LINK = """
-{% if record.pk %}
- {{ record.address }}
+{% if record.address or record.start_address %}
+ {{ record }}
{% elif perms.ipam.add_ipaddress %}
- {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
+ {{ record.title }}
{% else %}
- {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
+ {{ record.title }}
{% endif %}
"""
diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py
index aa1900e41..c22975be0 100644
--- a/netbox/ipam/tables/vlans.py
+++ b/netbox/ipam/tables/vlans.py
@@ -28,7 +28,7 @@ AVAILABLE_LABEL = mark_safe('Available first_ip_in_prefix:
- skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix)
- first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
- output.append((skipped_count, first_skipped))
+ if records[0][0] > first_ip_in_prefix:
+ output.append(AvailableIPSpace(
+ size=int(records[0][0] - first_ip_in_prefix),
+ first_ip=f'{first_ip_in_prefix}/{prefix.mask_length}'
+ ))
- # Iterate through existing IPs and annotate free ranges
- for ip in ipaddress_list:
+ # Add IP ranges & addresses, annotating available space in between records
+ for record in records:
if prev_ip:
- diff = int(ip.address.ip - prev_ip.address.ip)
- if diff > 1:
- first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
- output.append((diff - 1, first_skipped))
- output.append(ip)
- prev_ip = ip
+ # Annotate available space
+ if (diff := int(record[0]) - int(prev_ip)) > 1:
+ first_skipped = f'{prev_ip + 1}/{prefix.mask_length}'
+ output.append(AvailableIPSpace(
+ size=diff - 1,
+ first_ip=first_skipped
+ ))
+
+ output.append(record[1])
+
+ # Update the previous IP address
+ if hasattr(record[1], 'end_address'):
+ prev_ip = record[1].end_address.ip
+ else:
+ prev_ip = record[0]
# Include any remaining available IPs
- if prev_ip.address.ip < last_ip_in_prefix:
- skipped_count = int(last_ip_in_prefix - prev_ip.address.ip)
- first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
- output.append((skipped_count, first_skipped))
+ if prev_ip < last_ip_in_prefix:
+ output.append(AvailableIPSpace(
+ size=int(last_ip_in_prefix - prev_ip),
+ first_ip=f'{prev_ip + 1}/{prefix.mask_length}'
+ ))
return output
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 944aa211e..75170ef6e 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -11,7 +11,6 @@ from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site
from ipam.tables import VLANTranslationRuleTable
from netbox.views import generic
-from tenancy.views import ObjectContactsView
from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
@@ -22,7 +21,7 @@ from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
from .models import *
-from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
+from .utils import add_requested_prefixes, add_available_vlans, annotate_ip_space
#
@@ -434,11 +433,6 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
table = tables.AggregateTable
-@register_model_view(Aggregate, 'contacts')
-class AggregateContactsView(ObjectContactsView):
- queryset = Aggregate.objects.all()
-
-
#
# Prefix/VLAN roles
#
@@ -625,7 +619,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
class PrefixIPAddressesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all()
child_model = IPAddress
- table = tables.IPAddressTable
+ table = tables.AnnotatedIPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html'
@@ -641,7 +635,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q') and not get_table_ordering(request, self.table):
- return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
+ return annotate_ip_space(parent)
return queryset
def get_extra_context(self, request, instance):
@@ -684,11 +678,6 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
table = tables.PrefixTable
-@register_model_view(Prefix, 'contacts')
-class PrefixContactsView(ObjectContactsView):
- queryset = Prefix.objects.all()
-
-
#
# IP Ranges
#
@@ -778,11 +767,6 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
table = tables.IPRangeTable
-@register_model_view(IPRange, 'contacts')
-class IPRangeContactsView(ObjectContactsView):
- queryset = IPRange.objects.all()
-
-
#
# IP addresses
#
@@ -964,11 +948,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
return parent.get_related_ips().restrict(request.user, 'view')
-@register_model_view(IPAddress, 'contacts')
-class IPAddressContactsView(ObjectContactsView):
- queryset = IPAddress.objects.all()
-
-
#
# VLAN groups
#
@@ -1476,8 +1455,3 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
-
-
-@register_model_view(Service, 'contacts')
-class ServiceContactsView(ObjectContactsView):
- queryset = Service.objects.all()
diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py
index e7d1ef574..db5ec184d 100644
--- a/netbox/netbox/api/fields.py
+++ b/netbox/netbox/api/fields.py
@@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
__all__ = (
+ 'AttributesField',
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
@@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
def to_representation(self, instance):
return instance.lower, instance.upper - 1
+
+
+class AttributesField(serializers.JSONField):
+ """
+ Custom attributes stored as JSON data.
+ """
+ def to_internal_value(self, data):
+ data = super().to_internal_value(data)
+
+ # If updating an object, start with the initial attribute data. This enables the client to modify
+ # individual attributes without having to rewrite the entire field.
+ if data and self.parent.instance:
+ initial_data = getattr(self.parent.instance, self.source, None) or {}
+ return {**initial_data, **data}
+
+ return data
diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index d58d1affe..1befda371 100644
--- a/netbox/netbox/api/views.py
+++ b/netbox/netbox/api/views.py
@@ -4,15 +4,15 @@ from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django_rq.queues import get_connection
-from drf_spectacular.utils import extend_schema
from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.views import APIView
from rq.worker import Worker
-from netbox.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
+from netbox.plugins.utils import get_installed_plugins
class APIRootView(APIView):
@@ -66,7 +66,8 @@ class StatusView(APIView):
return Response({
'django-version': DJANGO_VERSION,
'installed-apps': installed_apps,
- 'netbox-version': settings.RELEASE.full_version,
+ 'netbox-version': settings.RELEASE.version,
+ 'netbox-full-version': settings.RELEASE.full_version,
'plugins': get_installed_plugins(),
'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')),
diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py
index e07e2c78b..e21be2348 100644
--- a/netbox/netbox/api/viewsets/mixins.py
+++ b/netbox/netbox/api/viewsets/mixins.py
@@ -45,7 +45,7 @@ class ExportTemplatesMixin:
if et is None:
raise Http404
queryset = self.filter_queryset(self.get_queryset())
- return et.render_to_response(queryset)
+ return et.render_to_response(queryset=queryset)
return super().list(request, *args, **kwargs)
diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py
index 84ead5339..27d44a2ff 100644
--- a/netbox/netbox/configuration_example.py
+++ b/netbox/netbox/configuration_example.py
@@ -12,14 +12,16 @@ ALLOWED_HOSTS = []
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
-DATABASE = {
- 'ENGINE': 'django.db.backends.postgresql', # Database engine
- 'NAME': 'netbox', # Database name
- 'USER': '', # PostgreSQL username
- 'PASSWORD': '', # PostgreSQL password
- 'HOST': 'localhost', # Database server
- 'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.postgresql', # Database engine
+ 'NAME': 'netbox', # Database name
+ 'USER': '', # PostgreSQL username
+ 'PASSWORD': '', # PostgreSQL password
+ 'HOST': 'localhost', # Database server
+ 'PORT': '', # Database port (leave blank for default)
+ 'CONN_MAX_AGE': 300, # Max database connection age
+ }
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
@@ -164,6 +166,9 @@ LOGIN_REQUIRED = True
# re-authenticate. (Default: 1209600 [14 days])
LOGIN_TIMEOUT = None
+# Hide the login form. Useful when only allowing SSO authentication.
+LOGIN_FORM_HIDDEN = False
+
# The view name or URL to which users are redirected after logging out.
LOGOUT_REDIRECT_URL = 'home'
diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py
index cec05cabb..52973e94d 100644
--- a/netbox/netbox/configuration_testing.py
+++ b/netbox/netbox/configuration_testing.py
@@ -5,13 +5,15 @@
ALLOWED_HOSTS = ['*']
-DATABASE = {
- 'NAME': 'netbox',
- 'USER': 'netbox',
- 'PASSWORD': 'netbox',
- 'HOST': 'localhost',
- 'PORT': '',
- 'CONN_MAX_AGE': 300,
+DATABASES = {
+ 'default': {
+ 'NAME': 'netbox',
+ 'USER': 'netbox',
+ 'PASSWORD': 'netbox',
+ 'HOST': 'localhost',
+ 'PORT': '',
+ 'CONN_MAX_AGE': 300,
+ }
}
PLUGINS = [
@@ -41,6 +43,8 @@ SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {}
+ALLOW_TOKEN_RETRIEVAL = True
+
LOGGING = {
'version': 1,
'disable_existing_loggers': True
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
index b8fbe7ad5..ffae9b7ff 100644
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -1,3 +1,5 @@
+import json
+
import django_filters
from copy import deepcopy
from django.contrib.contenttypes.models import ContentType
@@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
from utilities import filters
__all__ = (
+ 'AttributeFiltersMixin',
'BaseFilterSet',
'ChangeLoggedModelFilterSet',
'NetBoxModelFilterSet',
@@ -329,3 +332,48 @@ 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
+
+
+class AttributeFiltersMixin:
+ attributes_field_name = 'attribute_data'
+ attribute_filter_prefix = 'attr_'
+
+ def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
+ self.attr_filters = {}
+
+ # Extract JSONField-based filters from the incoming data
+ if data is not None:
+ for key, value in data.items():
+ if field := self._get_field_lookup(key):
+ # Attempt to cast the value to a native JSON type
+ try:
+ self.attr_filters[field] = json.loads(value)
+ except (ValueError, json.JSONDecodeError):
+ self.attr_filters[field] = value
+
+ super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
+
+ def _get_field_lookup(self, key):
+ if not key.startswith(self.attribute_filter_prefix):
+ return
+ lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix
+ return f'{self.attributes_field_name}__{lookup}'
+
+ def filter_queryset(self, queryset):
+ return super().filter_queryset(queryset).filter(**self.attr_filters)
diff --git a/netbox/netbox/graphql/enums.py b/netbox/netbox/graphql/enums.py
new file mode 100644
index 000000000..df62f8b3d
--- /dev/null
+++ b/netbox/netbox/graphql/enums.py
@@ -0,0 +1,13 @@
+import strawberry
+
+from netbox.choices import *
+
+__all__ = (
+ 'ColorEnum',
+ 'DistanceUnitEnum',
+ 'WeightUnitEnum',
+)
+
+ColorEnum = strawberry.enum(ColorChoices.as_enum())
+DistanceUnitEnum = strawberry.enum(DistanceUnitChoices.as_enum())
+WeightUnitEnum = strawberry.enum(WeightUnitChoices.as_enum())
diff --git a/netbox/netbox/graphql/filter_lookups.py b/netbox/netbox/graphql/filter_lookups.py
new file mode 100644
index 000000000..859236e4d
--- /dev/null
+++ b/netbox/netbox/graphql/filter_lookups.py
@@ -0,0 +1,219 @@
+from enum import Enum
+from typing import TypeVar, Tuple, Generic
+
+import strawberry
+import strawberry_django
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import Q, QuerySet
+from django.db.models.fields.related import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel
+from strawberry import ID
+from strawberry.types import Info
+from strawberry_django import (
+ ComparisonFilterLookup,
+ DateFilterLookup,
+ DatetimeFilterLookup,
+ FilterLookup,
+ RangeLookup,
+ TimeFilterLookup,
+ process_filters,
+)
+
+__all__ = (
+ 'ArrayLookup',
+ 'FloatArrayLookup',
+ 'FloatLookup',
+ 'IntegerArrayLookup',
+ 'IntegerLookup',
+ 'JSONFilter',
+ 'StringArrayLookup',
+ 'TreeNodeFilter',
+)
+
+T = TypeVar('T')
+SKIP_MSG = 'Filter will be skipped on `null` value'
+
+
+@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
+class JSONLookup:
+ string_lookup: FilterLookup[str] | None = strawberry_django.filter_field()
+ int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
+ int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
+ float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
+ float_comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
+ date_lookup: DateFilterLookup[str] | None = strawberry_django.filter_field()
+ datetime_lookup: DatetimeFilterLookup[str] | None = strawberry_django.filter_field()
+ time_lookup: TimeFilterLookup[str] | None = strawberry_django.filter_field()
+ boolean_lookup: FilterLookup[bool] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+
+@strawberry.input(one_of=True, description='Lookup for Integer fields. Only one of the lookup fields can be set.')
+class IntegerLookup:
+ filter_lookup: FilterLookup[int] | None = strawberry_django.filter_field()
+ range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
+ comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+
+
+@strawberry.input(one_of=True, description='Lookup for Float fields. Only one of the lookup fields can be set.')
+class FloatLookup:
+ filter_lookup: FilterLookup[float] | None = strawberry_django.filter_field()
+ range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
+ comparison_lookup: ComparisonFilterLookup[float] | None = strawberry_django.filter_field()
+
+ def get_filter(self):
+ for field in self.__strawberry_definition__.fields:
+ value = getattr(self, field.name, None)
+ if value is not strawberry.UNSET:
+ return value
+ return None
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=prefix)
+
+
+@strawberry.input
+class JSONFilter:
+ """
+ Class for JSON field lookups with paths
+ """
+
+ path: str
+ lookup: JSONLookup
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ filters = self.lookup.get_filter()
+
+ if not filters:
+ return queryset, Q()
+
+ json_path = f'{prefix}{self.path}__'
+ return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path)
+
+
+@strawberry.enum
+class TreeNodeMatch(Enum):
+ EXACT = 'exact' # Just the node itself
+ DESCENDANTS = 'descendants' # Node and all descendants
+ SELF_AND_DESCENDANTS = 'self_and_descendants' # Node and all descendants
+ CHILDREN = 'children' # Just immediate children
+ SIBLINGS = 'siblings' # Nodes with same parent
+ ANCESTORS = 'ancestors' # All parent nodes
+ PARENT = 'parent' # Just immediate parent
+
+
+@strawberry.input
+class TreeNodeFilter:
+ id: ID
+ match_type: TreeNodeMatch
+
+ @strawberry_django.filter_field
+ def filter(self, info: Info, queryset: QuerySet, prefix: str = '') -> Tuple[QuerySet, Q]:
+ model_field_name = prefix.removesuffix('__').removesuffix('_id')
+ model_field = None
+ try:
+ model_field = queryset.model._meta.get_field(model_field_name)
+ except FieldDoesNotExist:
+ try:
+ model_field = queryset.model._meta.get_field(f'{model_field_name}s')
+ except FieldDoesNotExist:
+ return queryset, Q(pk__in=[])
+
+ if hasattr(model_field, 'related_model'):
+ related_model = model_field.related_model
+ else:
+ return queryset, Q(pk__in=[])
+
+ # Generate base Q filter for the related model without prefix
+ q_filter = generate_tree_node_q_filter(related_model, self)
+
+ # Handle different relationship types
+ if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
+ return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
+ elif isinstance(model_field, ForeignKey):
+ return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
+ elif isinstance(model_field, ManyToOneRel):
+ return queryset, Q(**{f'{model_field_name}__in': related_model.objects.filter(q_filter)})
+ else:
+ return queryset, Q(**{f'{model_field_name}__{k}': v for k, v in q_filter.children})
+
+
+def generate_tree_node_q_filter(model_class, filter_value: TreeNodeFilter) -> Q:
+ """
+ Generate appropriate Q filter for MPTT tree filtering based on match type
+ """
+ try:
+ node = model_class.objects.get(id=filter_value.id)
+ except model_class.DoesNotExist:
+ return Q(pk__in=[])
+
+ if filter_value.match_type == TreeNodeMatch.EXACT:
+ return Q(id=filter_value.id)
+ elif filter_value.match_type == TreeNodeMatch.DESCENDANTS:
+ return Q(tree_id=node.tree_id, lft__gt=node.lft, rght__lt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.SELF_AND_DESCENDANTS:
+ return Q(tree_id=node.tree_id, lft__gte=node.lft, rght__lte=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.CHILDREN:
+ return Q(tree_id=node.tree_id, level=node.level + 1, lft__gt=node.lft, rght__lt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.SIBLINGS:
+ return Q(tree_id=node.tree_id, level=node.level, parent=node.parent) & ~Q(id=node.id)
+ elif filter_value.match_type == TreeNodeMatch.ANCESTORS:
+ return Q(tree_id=node.tree_id, lft__lt=node.lft, rght__gt=node.rght)
+ elif filter_value.match_type == TreeNodeMatch.PARENT:
+ return Q(id=node.parent_id) if node.parent_id else Q(pk__in=[])
+ return Q()
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class ArrayLookup(Generic[T]):
+ """
+ Class for Array field lookups
+ """
+
+ contains: list[T] | None = strawberry_django.filter_field(description='Contains the value')
+ contained_by: list[T] | None = strawberry_django.filter_field(description='Contained by the value')
+ overlap: list[T] | None = strawberry_django.filter_field(description='Overlaps with the value')
+ length: int | None = strawberry_django.filter_field(description='Length of the array')
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class IntegerArrayLookup(ArrayLookup[int]):
+ pass
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class FloatArrayLookup(ArrayLookup[float]):
+ pass
+
+
+@strawberry.input(one_of=True, description='Lookup for Array fields. Only one of the lookup fields can be set.')
+class StringArrayLookup(ArrayLookup[str]):
+ pass
diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py
index 2044a1dde..ce6d81a86 100644
--- a/netbox/netbox/graphql/filter_mixins.py
+++ b/netbox/netbox/graphql/filter_mixins.py
@@ -1,209 +1,109 @@
-from functools import partialmethod
-from typing import List
+from dataclasses import dataclass
+from datetime import datetime
+from typing import TypeVar, TYPE_CHECKING, Annotated
-import django_filters
import strawberry
import strawberry_django
-from django.core.exceptions import FieldDoesNotExist
-from strawberry import auto
+from strawberry import ID
+from strawberry_django import FilterLookup, DatetimeFilterLookup
-from ipam.fields import ASNField
-from netbox.graphql.scalars import BigInt
-from utilities.fields import ColorField, CounterCacheField
-from utilities.filters import *
+from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
+from netbox.graphql.filter_lookups import IntegerLookup
+
+__all__ = (
+ 'DistanceFilterMixin',
+ 'ImageAttachmentFilterMixin',
+ 'NestedGroupModelFilterMixin',
+ 'NetBoxModelFilterMixin',
+ 'OrganizationalModelFilterMixin',
+ 'PrimaryModelFilterMixin',
+ 'SyncedDataFilterMixin',
+ 'WeightFilterMixin',
+)
+
+T = TypeVar('T')
-def map_strawberry_type(field):
- should_create_function = False
- attr_type = None
-
- # NetBox Filter types - put base classes after derived classes
- if isinstance(field, ContentTypeFilter):
- should_create_function = True
- attr_type = str | None
- elif isinstance(field, MultiValueArrayFilter):
- pass
- elif isinstance(field, MultiValueCharFilter):
- # Note: Need to use the legacy FilterLookup from filters, not from
- # strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
- attr_type = strawberry_django.filters.FilterLookup[str] | None
- elif isinstance(field, MultiValueDateFilter):
- attr_type = auto
- elif isinstance(field, MultiValueDateTimeFilter):
- attr_type = auto
- elif isinstance(field, MultiValueDecimalFilter):
- pass
- elif isinstance(field, MultiValueMACAddressFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, MultiValueNumberFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, MultiValueTimeFilter):
- pass
- elif isinstance(field, MultiValueWWNFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, NullableCharFieldFilter):
- pass
- elif isinstance(field, NumericArrayFilter):
- should_create_function = True
- attr_type = int | None
- elif isinstance(field, TreeNodeMultipleChoiceFilter):
- should_create_function = True
- attr_type = List[str] | None
-
- # From django_filters - ordering of these matters as base classes must
- # come after derived classes so the base class doesn't get matched first
- # a pass for the check (no attr_type) means we don't currently handle
- # or use that type
- elif issubclass(type(field), django_filters.OrderingFilter):
- pass
- elif issubclass(type(field), django_filters.BaseRangeFilter):
- pass
- elif issubclass(type(field), django_filters.BaseInFilter):
- pass
- elif issubclass(type(field), django_filters.LookupChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.AllValuesMultipleFilter):
- pass
- elif issubclass(type(field), django_filters.AllValuesFilter):
- pass
- elif issubclass(type(field), django_filters.TimeRangeFilter):
- pass
- elif issubclass(type(field), django_filters.IsoDateTimeFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateTimeFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateFromToRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DateRangeFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.RangeFilter):
- pass
- elif issubclass(type(field), django_filters.NumericRangeFilter):
- pass
- elif issubclass(type(field), django_filters.NumberFilter):
- should_create_function = True
- attr_type = int | None
- elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
- should_create_function = True
- attr_type = List[str] | None
- elif issubclass(type(field), django_filters.ModelChoiceFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.DurationFilter):
- pass
- elif issubclass(type(field), django_filters.IsoDateTimeFilter):
- pass
- elif issubclass(type(field), django_filters.DateTimeFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.TimeFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.DateFilter):
- attr_type = auto
- elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.MultipleChoiceFilter):
- attr_type = str | None
- elif issubclass(type(field), django_filters.TypedChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.ChoiceFilter):
- pass
- elif issubclass(type(field), django_filters.BooleanFilter):
- should_create_function = True
- attr_type = bool | None
- elif issubclass(type(field), django_filters.UUIDFilter):
- should_create_function = True
- attr_type = str | None
- elif issubclass(type(field), django_filters.CharFilter):
- # looks like only used by 'q'
- should_create_function = True
- attr_type = str | None
-
- return should_create_function, attr_type
+if TYPE_CHECKING:
+ from .enums import *
+ from core.graphql.filters import *
+ from extras.graphql.filters import *
-def autotype_decorator(filterset):
- """
- Decorator used to auto creates a dataclass used by Strawberry based on a filterset.
- Must go after the Strawberry decorator as follows:
-
- @strawberry_django.filter(models.Example, lookups=True)
- @autotype_decorator(filtersets.ExampleFilterSet)
- class ExampleFilter(BaseFilterMixin):
- pass
-
- The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields
- of the filterset, usually just a type specifier is generated, so for
- `fields = [created, ]` the dataclass would be:
-
- class ExampleFilter(BaseFilterMixin):
- created: auto
-
- For other filter fields a function needs to be created for Strawberry with the
- naming convention `filter_{fieldname}` which is auto detected and called by
- Strawberry, this function uses the filterset to handle the query.
- """
- def create_attribute_and_function(cls, fieldname, attr_type, should_create_function):
- if fieldname not in cls.__annotations__ and attr_type:
- cls.__annotations__[fieldname] = attr_type
-
- filter_name = f"filter_{fieldname}"
- if should_create_function and not hasattr(cls, filter_name):
- filter_by_filterset = getattr(cls, 'filter_by_filterset')
- setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname))
-
- def wrapper(cls):
- cls.filterset = filterset
- fields = filterset.get_fields()
- model = filterset._meta.model
- for fieldname in fields.keys():
- should_create_function = False
- attr_type = auto
- if fieldname not in cls.__annotations__:
- try:
- field = model._meta.get_field(fieldname)
- except FieldDoesNotExist:
- continue
-
- if isinstance(field, CounterCacheField):
- should_create_function = True
- attr_type = BigInt | None
- elif isinstance(field, ASNField):
- should_create_function = True
- attr_type = List[str] | None
- elif isinstance(field, ColorField):
- should_create_function = True
- attr_type = List[str] | None
-
- create_attribute_and_function(cls, fieldname, attr_type, should_create_function)
-
- declared_filters = filterset.declared_filters
- for fieldname, field in declared_filters.items():
-
- should_create_function, attr_type = map_strawberry_type(field)
- if attr_type is None:
- raise NotImplementedError(f"GraphQL Filter field unknown: {fieldname}: {field}")
-
- create_attribute_and_function(cls, fieldname, attr_type, should_create_function)
-
- return cls
-
- return wrapper
+class NetBoxModelFilterMixin(
+ ChangeLogFilterMixin,
+ CustomFieldsFilterMixin,
+ JournalEntriesFilterMixin,
+ TagsFilterMixin,
+ BaseObjectTypeFilterMixin,
+):
+ pass
-@strawberry.input
-class BaseFilterMixin:
+@dataclass
+class NestedGroupModelFilterMixin(NetBoxModelFilterMixin):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ lft: IntegerLookup | None = strawberry_django.filter_field()
+ rght: IntegerLookup | None = strawberry_django.filter_field()
+ tree_id: IntegerLookup | None = strawberry_django.filter_field()
+ level: IntegerLookup | None = strawberry_django.filter_field()
+ parent_id: ID | None = strawberry_django.filter_field()
- def filter_by_filterset(self, queryset, key):
- filterset = self.filterset(data={key: getattr(self, key)}, queryset=queryset)
- if not filterset.is_valid():
- # We could raise validation error but strawberry logs it all to the
- # console i.e. raise ValidationError(f"{k}: {v[0]}")
- return filterset.qs.none()
- return filterset.qs
+
+@dataclass
+class OrganizationalModelFilterMixin(
+ ChangeLogFilterMixin,
+ CustomFieldsFilterMixin,
+ TagsFilterMixin,
+ BaseObjectTypeFilterMixin,
+):
+ name: FilterLookup[str] | None = strawberry_django.filter_field()
+ slug: FilterLookup[str] | None = strawberry_django.filter_field()
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class PrimaryModelFilterMixin(NetBoxModelFilterMixin):
+ description: FilterLookup[str] | None = strawberry_django.filter_field()
+ comments: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class ImageAttachmentFilterMixin(BaseFilterMixin):
+ images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class WeightFilterMixin(BaseFilterMixin):
+ weight: FilterLookup[float] | None = strawberry_django.filter_field()
+ weight_unit: Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
+
+
+@dataclass
+class SyncedDataFilterMixin(BaseFilterMixin):
+ data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ data_source_id: FilterLookup[int] | None = strawberry_django.filter_field()
+ data_file: Annotated['DataFileFilter', strawberry.lazy('core.graphql.filters')] | None = (
+ strawberry_django.filter_field()
+ )
+ data_file_id: FilterLookup[int] | None = strawberry_django.filter_field()
+ data_path: FilterLookup[str] | None = strawberry_django.filter_field()
+ auto_sync_enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
+ data_synced: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class DistanceFilterMixin(BaseFilterMixin):
+ distance: FilterLookup[float] | None = strawberry_django.filter_field()
+ distance_unit: Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')] | None = (
+ strawberry_django.filter_field()
+ )
diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py
index a7609c9d2..c840e769c 100644
--- a/netbox/netbox/graphql/schema.py
+++ b/netbox/netbox/graphql/schema.py
@@ -1,7 +1,7 @@
import strawberry
from django.conf import settings
from strawberry_django.optimizer import DjangoOptimizerExtension
-from strawberry.extensions import MaxAliasesLimiter
+from strawberry.extensions import MaxAliasesLimiter # , SchemaExtension
from strawberry.schema.config import StrawberryConfig
from circuits.graphql.schema import CircuitsQuery
diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py
index a4fc99080..653462630 100644
--- a/netbox/netbox/graphql/types.py
+++ b/netbox/netbox/graphql/types.py
@@ -8,6 +8,7 @@ from extras.graphql.mixins import CustomFieldsMixin, JournalEntriesMixin, TagsMi
__all__ = (
'BaseObjectType',
+ 'ContentTypeType',
'ObjectType',
'OrganizationalObjectType',
'NetBoxObjectType',
@@ -83,6 +84,7 @@ class NetBoxObjectType(
@strawberry_django.type(
ContentType,
fields=['id', 'app_label', 'model'],
+ pagination=True
)
class ContentTypeType:
pass
@@ -91,6 +93,7 @@ class ContentTypeType:
@strawberry_django.type(
ObjectType_,
fields=['id', 'app_label', 'model'],
+ pagination=True
)
class ObjectTypeType:
pass
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index b9424bd7c..714c20e56 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -2,6 +2,7 @@ from contextlib import ExitStack
import logging
import uuid
+import warnings
from django.conf import settings
from django.contrib import auth, messages
@@ -37,7 +38,10 @@ class CoreMiddleware:
# Apply all registered request processors
with ExitStack() as stack:
for request_processor in registry['request_processors']:
- stack.enter_context(request_processor(request))
+ try:
+ stack.enter_context(request_processor(request))
+ except Exception as e:
+ warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
response = self.get_response(request)
# Check if language cookie should be renewed
@@ -94,18 +98,23 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
"""
Custom implementation of Django's RemoteUserMiddleware which allows for a user-configurable HTTP header name.
"""
+ async_capable = False
force_logout_if_no_header = False
+ def __init__(self, get_response):
+ if get_response is None:
+ raise ValueError("get_response must be provided.")
+ self.get_response = get_response
+
@property
def header(self):
return settings.REMOTE_AUTH_HEADER
- def process_request(self, request):
- logger = logging.getLogger(
- 'netbox.authentication.RemoteUserMiddleware')
+ def __call__(self, request):
+ logger = logging.getLogger('netbox.authentication.RemoteUserMiddleware')
# Bypass middleware if remote authentication is not enabled
if not settings.REMOTE_AUTH_ENABLED:
- return
+ return self.get_response(request)
# AuthenticationMiddleware is required so that request.user exists.
if not hasattr(request, 'user'):
raise ImproperlyConfigured(
@@ -122,13 +131,13 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
# AnonymousUser by the AuthenticationMiddleware).
if self.force_logout_if_no_header and request.user.is_authenticated:
self._remove_invalid_user(request)
- return
+ return self.get_response(request)
# If the user is already authenticated and that user is the user we are
# getting passed in the headers, then the correct user is already
# persisted in the session and we don't need to continue.
if request.user.is_authenticated:
if request.user.get_username() == self.clean_username(username, request):
- return
+ return self.get_response(request)
else:
# An authenticated user is associated with the request, but
# it does not match the authorized user in the header.
@@ -158,6 +167,8 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
request.user = user
auth.login(request, user)
+ return self.get_response(request)
+
def _get_groups(self, request):
logger = logging.getLogger(
'netbox.authentication.RemoteUserMiddleware')
diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py
index b1f7cfd48..3ad0ac556 100644
--- a/netbox/netbox/models/__init__.py
+++ b/netbox/netbox/models/__init__.py
@@ -150,6 +150,10 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
max_length=200,
blank=True
)
+ comments = models.TextField(
+ verbose_name=_('comments'),
+ blank=True
+ )
objects = TreeManager()
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index a97227770..55093e69d 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -5,6 +5,7 @@ from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import ValidationError
from django.db import models
+from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
@@ -19,7 +20,6 @@ from netbox.registry import registry
from netbox.signals import post_clean
from utilities.json import CustomFieldJSONEncoder
from utilities.serialization import serialize_object
-from utilities.views import register_model_view
__all__ = (
'BookmarksMixin',
@@ -352,7 +352,7 @@ class ImageAttachmentsMixin(models.Model):
class ContactsMixin(models.Model):
"""
- Enables the assignments of Contacts (via ContactAssignment).
+ Enables the assignment of Contacts to a model (via ContactAssignment).
"""
contacts = GenericRelation(
to='tenancy.ContactAssignment',
@@ -363,6 +363,27 @@ class ContactsMixin(models.Model):
class Meta:
abstract = True
+ def get_contacts(self, inherited=True):
+ """
+ Return a `QuerySet` matching all contacts assigned to this object.
+
+ Args:
+ inherited: If `True`, inherited contacts from parent objects are included.
+ """
+ from tenancy.models import ContactAssignment
+ from . import NestedGroupModel
+
+ filter = Q(
+ object_type=ObjectType.objects.get_for_model(self),
+ object_id__in=(
+ self.get_ancestors(include_self=True)
+ if (isinstance(self, NestedGroupModel) and inherited)
+ else [self.pk]
+ ),
+ )
+
+ return ContactAssignment.objects.filter(filter)
+
class BookmarksMixin(models.Model):
"""
@@ -434,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:
@@ -617,6 +639,8 @@ def register_models(*models):
register_model() should be called for each relevant model under the ready() of an app's AppConfig class.
"""
+ from utilities.views import register_model_view
+
for model in models:
app_label, model_name = model._meta.label_lower.split('.')
@@ -637,6 +661,10 @@ def register_models(*models):
)
# Register applicable feature views for the model
+ if issubclass(model, ContactsMixin):
+ register_model_view(model, 'contacts', kwargs={'model': model})(
+ 'netbox.views.generic.ObjectContactsView'
+ )
if issubclass(model, JournalingMixin):
register_model_view(model, 'journal', kwargs={'model': model})(
'netbox.views.generic.ObjectJournalView'
diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 9148caa8e..778f0d67c 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -85,6 +85,7 @@ DEVICES_MENU = Menu(
items=(
get_model_item('dcim', 'devicetype', _('Device Types')),
get_model_item('dcim', 'moduletype', _('Module Types')),
+ get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
),
),
diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py
index bb3280ac4..b7bb0ef9f 100644
--- a/netbox/netbox/plugins/__init__.py
+++ b/netbox/netbox/plugins/__init__.py
@@ -6,6 +6,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
from packaging import version
+from core.exceptions import IncompatiblePluginError
from netbox.registry import registry
from netbox.search import register_search
from netbox.utils import register_data_backend
@@ -140,14 +141,14 @@ class PluginConfig(AppConfig):
if cls.min_version is not None:
min_version = version.parse(cls.min_version)
if current_version < min_version:
- raise ImproperlyConfigured(
+ raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
- raise ImproperlyConfigured(
+ raise IncompatiblePluginError(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
)
diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py
index 515405f1b..0001d50c9 100644
--- a/netbox/netbox/plugins/registration.py
+++ b/netbox/netbox/plugins/registration.py
@@ -1,7 +1,7 @@
import inspect
-import warnings
from django.utils.translation import gettext_lazy as _
+
from netbox.registry import registry
from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
from .templates import PluginTemplateExtension
@@ -35,16 +35,8 @@ def register_template_extensions(class_list):
)
if template_extension.models:
- # Registration for multiple models
+ # Registration for specific models
models = template_extension.models
- elif template_extension.model:
- # Registration for a single model (deprecated)
- warnings.warn(
- "PluginTemplateExtension.model is deprecated and will be removed in a future release. Use "
- "'models' instead.",
- DeprecationWarning
- )
- models = [template_extension.model]
else:
# Global registration (no specific models)
models = [None]
diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py
index 4ea90b4db..586391d4f 100644
--- a/netbox/netbox/plugins/templates.py
+++ b/netbox/netbox/plugins/templates.py
@@ -11,8 +11,14 @@ class PluginTemplateExtension:
This class is used to register plugin content to be injected into core NetBox templates. It contains methods
that are overridden by plugin authors to return template content.
- The `model` attribute on the class defines the which model detail page this class renders content for. It
- should be set as a string in the form '{% trans "Manufacturer" %} | +{{ object.module_type.manufacturer|linkify }} | +
---|---|
{% trans "Model" %} | +{{ object.module_type|linkify }} | +
{{ k }} | +{{ v|placeholder }} | +
{% trans "Profile" %} | +{{ object.profile|linkify|placeholder }} | +|||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
{% trans "Manufacturer" %} | {{ object.manufacturer|linkify }} | @@ -60,6 +64,27 @@ {% plugin_left_page object %}
{{ k }} | +{{ v|placeholder }} | +
---|
{% trans "Name" %} | +{{ object.name }} | +
---|---|
{% trans "Description" %} | +{{ object.description|placeholder }} | +
{{ object.schema|json }}+
{% trans "Description" %} | {{ object.description|placeholder }} | +||||||
---|---|---|---|---|---|---|---|
{% trans "MIME Type" %} | +{{ object.mime_type|placeholder }} | +||||||
{% trans "File Name" %} | +{{ object.file_name|placeholder }} | +||||||
{% trans "File Extension" %} | +{{ object.file_extension|placeholder }} | +||||||
{% trans "Attachment" %} | +{% checkmark object.as_attachment %} | +||||||
{% trans "Data Source" %} |
@@ -51,7 +67,7 @@
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
-
+ {% trans "Environment Parameters" %}
diff --git a/netbox/templates/extras/exporttemplate.html b/netbox/templates/extras/exporttemplate.html
index 5a19426f2..fd1e56d11 100644
--- a/netbox/templates/extras/exporttemplate.html
+++ b/netbox/templates/extras/exporttemplate.html
@@ -6,8 +6,8 @@
{% block title %}{{ object.name }}{% endblock %}
{% block content %}
-
-
+
+ {% trans "Export Template" %}
{% trans "Assigned Models" %}
-
{% plugin_right_page object %}
@@ -87,6 +90,13 @@
{% trans "Template" %}+{% trans "Environment Parameters" %}
- {% include 'inc/sync_warning.html' %}
-
{{ object.template_code }}+ {{ object.environment_params }}
+
+
{% plugin_full_width_page object %}
{% trans "Template" %}+
+ {% include 'inc/sync_warning.html' %}
+
+ {{ object.template_code }}+ |
||||||
{% trans "Weight" %} | +{{ object.weight }} | +||||||
{% trans "Tagged Items" %} | diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index 19fbd5171..0de00ee45 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -25,12 +25,19 @@ | {% trans "Size" %} | {{ object.size }} | ||||
{% trans "Marked Populated" %} | +{% checkmark object.mark_populated %} | +||||||
{% trans "Marked Utilized" %} | +{% checkmark object.mark_utilized %} | +||||||
{% trans "Utilization" %} | {% if object.mark_utilized %} {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %} - ({% trans "Marked fully utilized" %}) {% else %} {% utilization_graph object.utilization %} {% endif %} diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index f994852be..abc998de3 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -46,6 +46,15 @@ | Utilization | {% utilization_graph object.utilization %} | ||||
{% trans "Tenant" %} | ++ {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + | +
{% trans "Group" %} | -{{ object.group|linkify|placeholder }} | +{% trans "Groups" %} | +
+ {% if object.groups.all|length > 0 %}
+
|
---|---|---|---|
{% trans "Name" %} | diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index bf6928c15..25b1da440 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -32,6 +32,7 @@