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//', include(get_model_urls('dcim', 'devicetype'))), + path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))), + path('module-type-profiles//', include(get_model_urls('dcim', 'moduletypeprofile'))), + path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))), path('module-types//', include(get_model_urls('dcim', 'moduletype'))), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 4d4228490..0931761bf 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db import transaction @@ -56,3 +57,22 @@ def rebuild_paths(terminations): for cp in cable_paths: cp.delete() create_cablepath(cp.origins) + + +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. + """ + Interface = apps.get_model('dcim', 'Interface') + + 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() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0978747d1..1e231ae3a 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -4,7 +4,6 @@ from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Prefetch from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape @@ -19,7 +18,6 @@ from ipam.models import ASN, IPAddress, Prefix, VLANGroup from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model @@ -304,11 +302,6 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable -@register_model_view(Region, 'contacts') -class RegionContactsView(ObjectContactsView): - queryset = Region.objects.all() - - # # Site groups # @@ -412,11 +405,6 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView): table = tables.SiteGroupTable -@register_model_view(SiteGroup, 'contacts') -class SiteGroupContactsView(ObjectContactsView): - queryset = SiteGroup.objects.all() - - # # Sites # @@ -494,11 +482,6 @@ class SiteBulkDeleteView(generic.BulkDeleteView): table = tables.SiteTable -@register_model_view(Site, 'contacts') -class SiteContactsView(ObjectContactsView): - queryset = Site.objects.all() - - # # Locations # @@ -596,11 +579,6 @@ class LocationBulkDeleteView(generic.BulkDeleteView): table = tables.LocationTable -@register_model_view(Location, 'contacts') -class LocationContactsView(ObjectContactsView): - queryset = Location.objects.all() - - # # Rack roles # @@ -887,11 +865,6 @@ class RackBulkDeleteView(generic.BulkDeleteView): table = tables.RackTable -@register_model_view(Rack, 'contacts') -class RackContactsView(ObjectContactsView): - queryset = Rack.objects.all() - - # # Rack reservations # @@ -1029,11 +1002,6 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): table = tables.ManufacturerTable -@register_model_view(Manufacturer, 'contacts') -class ManufacturerContactsView(ObjectContactsView): - queryset = Manufacturer.objects.all() - - # # Device types # @@ -1278,6 +1246,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTypeTable +# +# Module type profiles +# + +@register_model_view(ModuleTypeProfile, 'list', path='', detail=False) +class ModuleTypeProfileListView(generic.ObjectListView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(ModuleType, 'profile') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + filterset_form = forms.ModuleTypeProfileFilterForm + table = tables.ModuleTypeProfileTable + + +@register_model_view(ModuleTypeProfile) +class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView): + queryset = ModuleTypeProfile.objects.all() + + +@register_model_view(ModuleTypeProfile, 'add', detail=False) +@register_model_view(ModuleTypeProfile, 'edit') +class ModuleTypeProfileEditView(generic.ObjectEditView): + queryset = ModuleTypeProfile.objects.all() + form = forms.ModuleTypeProfileForm + + +@register_model_view(ModuleTypeProfile, 'delete') +class ModuleTypeProfileDeleteView(generic.ObjectDeleteView): + queryset = ModuleTypeProfile.objects.all() + + +@register_model_view(ModuleTypeProfile, 'bulk_import', detail=False) +class ModuleTypeProfileBulkImportView(generic.BulkImportView): + queryset = ModuleTypeProfile.objects.all() + model_form = forms.ModuleTypeProfileImportForm + + +@register_model_view(ModuleTypeProfile, 'bulk_edit', path='edit', detail=False) +class ModuleTypeProfileBulkEditView(generic.BulkEditView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + table = tables.ModuleTypeProfileTable + form = forms.ModuleTypeProfileBulkEditForm + + +@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False) +class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): + queryset = ModuleTypeProfile.objects.annotate( + instance_count=count_related(Module, 'module_type') + ) + filterset = filtersets.ModuleTypeProfileFilterSet + table = tables.ModuleTypeProfileTable + + # # Module types # @@ -2268,10 +2292,7 @@ class DeviceRenderConfigView(generic.ObjectView): # If a direct export has been requested, return the rendered template content as a # downloadable file. if request.GET.get('export'): - content = context['rendered_config'] or context['error_message'] - response = HttpResponse(content, content_type='text') - filename = f"{instance.name or 'config'}.txt" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response = context['config_template'].render_to_response(context=context['context_data']) return response return render(request, self.get_template_name(), { @@ -2360,11 +2381,6 @@ class DeviceBulkRenameView(generic.BulkRenameView): table = tables.DeviceTable -@register_model_view(Device, 'contacts') -class DeviceContactsView(ObjectContactsView): - queryset = Device.objects.all() - - # # Modules # @@ -3924,11 +3940,6 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPanelTable -@register_model_view(PowerPanel, 'contacts') -class PowerPanelContactsView(ObjectContactsView): - queryset = PowerPanel.objects.all() - - # # Power feeds # diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index c4a683c74..69652907e 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -22,6 +22,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer model = ConfigTemplate fields = [ 'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code', - 'data_source', 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated', + 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', + 'data_synced', 'tags', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index 11f502a02..0d19d642c 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -26,8 +26,8 @@ class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate fields = [ - 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created', - 'last_updated', + 'id', 'url', 'display_url', 'display', 'object_types', 'name', 'description', 'environment_params', + 'template_code', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', + 'data_path', 'data_file', 'data_synced', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index e4e62845a..5dc39584f 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -1,10 +1,16 @@ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from core.models import ObjectType -from extras.models import Tag +from extras.models import Tag, TaggedItem +from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer +from utilities.api import get_serializer_for_model __all__ = ( 'TagSerializer', + 'TaggedItemSerializer', ) @@ -21,7 +27,41 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'object_types', - 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'weight', + 'object_types', 'tagged_items', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description') + + +class TaggedItemSerializer(BaseModelSerializer): + object_type = ContentTypeField( + source='content_type', + read_only=True + ) + object = serializers.SerializerMethodField( + read_only=True + ) + tag = TagSerializer( + nested=True, + read_only=True + ) + + class Meta: + model = TaggedItem + fields = [ + 'id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag', + ] + brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'object', 'tag') + + @extend_schema_field(serializers.JSONField()) + def get_object(self, obj): + """ + Serialize a nested representation of the tagged object. + """ + try: + serializer = get_serializer_for_model(obj.content_object) + except SerializerNotFound: + return obj.object_repr + data = serializer(obj.content_object, nested=True, context={'request': self.context['request']}).data + + return data diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bbcb8f0ef..88121b640 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -19,6 +19,7 @@ router.register('notifications', views.NotificationViewSet) router.register('notification-groups', views.NotificationGroupViewSet) router.register('subscriptions', views.SubscriptionViewSet) router.register('tags', views.TagViewSet) +router.register('tagged-objects', views.TaggedItemViewSet) router.register('image-attachments', views.ImageAttachmentViewSet) router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index e4c3c7f3e..49a44f5f1 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -6,6 +6,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.generics import RetrieveUpdateDestroyAPIView +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView @@ -20,7 +21,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.features import SyncedDataMixin from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException from utilities.request import copy_safe_request from . import serializers @@ -172,6 +173,12 @@ class TagViewSet(NetBoxModelViewSet): filterset_class = filtersets.TagFilterSet +class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet): + queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag') + serializer_class = serializers.TaggedItemSerializer + filterset_class = filtersets.TaggedItemFilterSet + + # # Image attachments # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 4525d8689..3ecc7e57f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -212,23 +212,6 @@ class WebhookHttpMethodChoices(ChoiceSet): ) -# -# Staging -# - -class ChangeActionChoices(ChoiceSet): - - ACTION_CREATE = 'create' - ACTION_UPDATE = 'update' - ACTION_DELETE = 'delete' - - CHOICES = ( - (ACTION_CREATE, _('Create'), 'green'), - (ACTION_UPDATE, _('Update'), 'blue'), - (ACTION_DELETE, _('Delete'), 'red'), - ) - - # # Dashboard widgets # diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 123b771f6..db378c8fa 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -4,6 +4,9 @@ from extras.choices import LogLevelChoices # Custom fields CUSTOMFIELD_EMPTY_VALUES = (None, '', []) +# Template Export +DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8' + # Webhooks HTTP_CONTENT_TYPE_JSON = 'application/json' diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index eeed5414f..c04f8f423 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -17,6 +17,7 @@ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model +from utilities.proxy import resolve_proxies from utilities.querydict import dict_to_querydict from utilities.templatetags.builtins.filters import render_markdown from utilities.views import get_viewname @@ -330,7 +331,7 @@ class RSSFeedWidget(DashboardWidget): response = requests.get( url=self.config['feed_url'], headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, - proxies=settings.HTTP_PROXIES, + proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}), timeout=3 ) response.raise_for_status() diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 4f40ce500..89dd4c9f1 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -8,7 +8,9 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from users.models import Group, User -from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +from utilities.filters import ( + ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +) from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * from .filters import TagFilter @@ -31,6 +33,7 @@ __all__ = ( 'SavedFilterFilterSet', 'ScriptFilterSet', 'TagFilterSet', + 'TaggedItemFilterSet', 'WebhookFilterSet', ) @@ -257,8 +260,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ExportTemplate fields = ( - 'id', 'name', 'description', 'mime_type', 'file_extension', 'as_attachment', 'auto_sync_enabled', - 'data_synced', + 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'auto_sync_enabled', 'data_synced', ) def search(self, queryset, name, value): @@ -266,7 +269,8 @@ class ExportTemplateFilterSet(ChangeLoggedModelFilterSet): return queryset return queryset.filter( Q(name__icontains=value) | - Q(description__icontains=value) + Q(description__icontains=value) | + Q(file_name__icontains=value) ) @@ -449,7 +453,7 @@ class TagFilterSet(ChangeLoggedModelFilterSet): class Meta: model = Tag - fields = ('id', 'name', 'slug', 'color', 'description', 'object_types') + fields = ('id', 'name', 'slug', 'color', 'weight', 'description', 'object_types') def search(self, queryset, name, value): if not value.strip(): @@ -492,6 +496,41 @@ class TagFilterSet(ChangeLoggedModelFilterSet): ) +class TaggedItemFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + object_type = ContentTypeFilter( + field_name='content_type' + ) + object_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=ContentType.objects.all(), + field_name='content_type_id' + ) + tag_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tag.objects.all() + ) + tag = django_filters.ModelMultipleChoiceFilter( + field_name='tag__slug', + queryset=Tag.objects.all(), + to_field_name='slug', + ) + + class Meta: + model = TaggedItem + fields = ('id', 'object_id') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(tag__name__icontains=value) | + Q(tag__slug__icontains=value) | + Q(tag__description__icontains=value) + ) + + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -668,7 +707,10 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ConfigTemplate - fields = ('id', 'name', 'description', 'auto_sync_enabled', 'data_synced') + fields = ( + 'id', 'name', 'description', 'mime_type', 'file_name', 'file_extension', 'as_attachment', + 'auto_sync_enabled', 'data_synced' + ) def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 30d06683b..7a78dba8b 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -155,6 +155,10 @@ class ExportTemplateBulkEditForm(BulkEditForm): max_length=50, required=False ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), max_length=15, @@ -166,7 +170,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): widget=BulkEditNullBooleanSelect() ) - nullable_fields = ('description', 'mime_type', 'file_extension') + nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') class SavedFilterBulkEditForm(BulkEditForm): @@ -275,6 +279,10 @@ class TagBulkEditForm(BulkEditForm): max_length=200, required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) nullable_fields = ('description',) @@ -313,8 +321,27 @@ class ConfigTemplateBulkEditForm(BulkEditForm): max_length=200, required=False ) + mime_type = forms.CharField( + label=_('MIME type'), + max_length=50, + required=False + ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) + file_extension = forms.CharField( + label=_('File extension'), + max_length=15, + required=False + ) + as_attachment = forms.NullBooleanField( + label=_('As attachment'), + required=False, + widget=BulkEditNullBooleanSelect() + ) - nullable_fields = ('description',) + nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') class JournalEntryBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 655a5d6ca..7c270da4a 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -144,7 +144,8 @@ class ExportTemplateImportForm(CSVModelForm): class Meta: model = ExportTemplate fields = ( - 'name', 'object_types', 'description', 'mime_type', 'file_extension', 'as_attachment', 'template_code', + 'name', 'object_types', 'description', 'environment_params', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'template_code', ) @@ -153,7 +154,8 @@ class ConfigTemplateImportForm(CSVModelForm): class Meta: model = ConfigTemplate fields = ( - 'name', 'description', 'environment_params', 'template_code', 'tags', + 'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension', + 'as_attachment', 'tags', ) @@ -232,10 +234,14 @@ class EventRuleImportForm(NetBoxModelImportForm): class TagImportForm(CSVModelForm): slug = SlugField() + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) class Meta: model = Tag - fields = ('name', 'slug', 'color', 'description') + fields = ('name', 'slug', 'color', 'weight', 'description') class JournalEntryImportForm(NetBoxModelImportForm): diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 05dcf96c4..056ca62a5 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -160,9 +160,9 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( - FieldSet('q', 'filter_id'), + FieldSet('q', 'filter_id', 'object_type_id'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), - FieldSet('object_type_id', 'mime_type', 'file_extension', 'as_attachment', name=_('Attributes')), + FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')), ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -186,6 +186,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('MIME type') ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) file_extension = forms.CharField( label=_('File extension'), required=False @@ -318,7 +322,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): FieldSet('q', 'filter_id', 'tag_id'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), - FieldSet('device_type_id', 'platform_id', 'role_id', name=_('Device')), + FieldSet('device_type_id', 'platform_id', 'device_role_id', name=_('Device')), FieldSet('cluster_type_id', 'cluster_group_id', 'cluster_id', name=_('Cluster')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')) ) @@ -360,7 +364,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Device types') ) - role_id = DynamicModelMultipleChoiceField( + device_role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, label=_('Roles') @@ -406,6 +410,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('data_source_id', 'data_file_id', name=_('Data')), + FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering')) ) data_source_id = DynamicModelMultipleChoiceField( queryset=DataSource.objects.all(), @@ -421,6 +426,25 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm): } ) tag = TagFilterField(ConfigTemplate) + mime_type = forms.CharField( + required=False, + label=_('MIME type') + ) + file_name = forms.CharField( + label=_('File name'), + required=False + ) + file_extension = forms.CharField( + label=_('File extension'), + required=False + ) + as_attachment = forms.NullBooleanField( + label=_('As attachment'), + required=False, + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) class LocalConfigContextFilterForm(forms.Form): diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index a45daaf70..594b7d9d0 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -246,7 +246,9 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): fieldsets = ( FieldSet('name', 'object_types', 'description', 'template_code', name=_('Export Template')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), - FieldSet('mime_type', 'file_extension', 'as_attachment', name=_('Rendering')), + FieldSet( + 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering') + ), ) class Meta: @@ -490,15 +492,19 @@ class TagForm(forms.ModelForm): queryset=ObjectType.objects.with_feature('tags'), required=False ) + weight = forms.IntegerField( + label=_('Weight'), + required=False + ) fieldsets = ( - FieldSet('name', 'slug', 'color', 'description', 'object_types', name=_('Tag')), + FieldSet('name', 'slug', 'color', 'weight', 'description', 'object_types', name=_('Tag')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description', 'object_types', + 'name', 'slug', 'color', 'weight', 'description', 'object_types', ] @@ -627,9 +633,11 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): ) fieldsets = ( - FieldSet('name', 'description', 'environment_params', 'tags', name=_('Config Template')), - FieldSet('template_code', name=_('Content')), + FieldSet('name', 'description', 'tags', 'template_code', name=_('Config Template')), FieldSet('data_source', 'data_file', 'auto_sync_enabled', name=_('Data Source')), + FieldSet( + 'mime_type', 'file_name', 'file_extension', 'environment_params', 'as_attachment', name=_('Rendering') + ), ) class Meta: diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index 331f7f01f..764246a2d 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -1,11 +1,18 @@ +import os + from django import forms +from django.conf import settings +from django.core.files.storage import storages from django.utils.translation import gettext_lazy as _ +from core.forms import ManagedFileForm from extras.choices import DurationChoices +from extras.storage import ScriptFileSystemStorage from utilities.forms.widgets import DateTimePicker, NumberWithOptions from utilities.datetime import local_now __all__ = ( + 'ScriptFileForm', 'ScriptForm', ) @@ -55,3 +62,26 @@ class ScriptForm(forms.Form): self.cleaned_data['_schedule_at'] = local_now() return self.cleaned_data + + +class ScriptFileForm(ManagedFileForm): + """ + ManagedFileForm with a custom save method to use django-storages. + """ + def save(self, *args, **kwargs): + # If a file was uploaded, save it to disk + if self.cleaned_data['upload_file']: + storage = storages.create_storage(storages.backends["scripts"]) + + filename = self.cleaned_data['upload_file'].name + if isinstance(storage, ScriptFileSystemStorage): + full_path = os.path.join(settings.SCRIPTS_ROOT, filename) + else: + full_path = filename + + self.instance.file_path = full_path + data = self.cleaned_data['upload_file'] + storage.save(filename, data) + + # need to skip ManagedFileForm save method + return super(ManagedFileForm, self).save(*args, **kwargs) diff --git a/netbox/extras/graphql/enums.py b/netbox/extras/graphql/enums.py new file mode 100644 index 000000000..0d352b835 --- /dev/null +++ b/netbox/extras/graphql/enums.py @@ -0,0 +1,26 @@ +import strawberry + +from extras.choices import * + +__all__ = ( + 'CustomFieldChoiceSetBaseEnum', + 'CustomFieldFilterLogicEnum', + 'CustomFieldTypeEnum', + 'CustomFieldUIEditableEnum', + 'CustomFieldUIVisibleEnum', + 'CustomLinkButtonClassEnum', + 'EventRuleActionEnum', + 'JournalEntryKindEnum', + 'WebhookHttpMethodEnum', +) + + +CustomFieldChoiceSetBaseEnum = strawberry.enum(CustomFieldChoiceSetBaseChoices.as_enum()) +CustomFieldFilterLogicEnum = strawberry.enum(CustomFieldFilterLogicChoices.as_enum()) +CustomFieldTypeEnum = strawberry.enum(CustomFieldTypeChoices.as_enum()) +CustomFieldUIEditableEnum = strawberry.enum(CustomFieldUIEditableChoices.as_enum()) +CustomFieldUIVisibleEnum = strawberry.enum(CustomFieldUIVisibleChoices.as_enum()) +CustomLinkButtonClassEnum = strawberry.enum(CustomLinkButtonClassChoices.as_enum()) +EventRuleActionEnum = strawberry.enum(EventRuleActionChoices.as_enum()) +JournalEntryKindEnum = strawberry.enum(JournalEntryKindChoices.as_enum()) +WebhookHttpMethodEnum = strawberry.enum(WebhookHttpMethodChoices.as_enum()) diff --git a/netbox/extras/graphql/filter_mixins.py b/netbox/extras/graphql/filter_mixins.py new file mode 100644 index 000000000..7e9a970f2 --- /dev/null +++ b/netbox/extras/graphql/filter_mixins.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import JSONFilter + from .filters import * + +__all__ = ( + 'CustomFieldsFilterMixin', + 'JournalEntriesFilterMixin', + 'TagsFilterMixin', + 'ConfigContextFilterMixin', + 'TagBaseFilterMixin', +) + + +@dataclass +class CustomFieldsFilterMixin(BaseFilterMixin): + custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class JournalEntriesFilterMixin(BaseFilterMixin): + journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagsFilterMixin(BaseFilterMixin): + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + + +@dataclass +class ConfigContextFilterMixin(BaseFilterMixin): + local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TagBaseFilterMixin(BaseFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index ff2e6a0f1..b8db143e4 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -1,7 +1,26 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from extras 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 BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from extras import models +from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import SyncedDataFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from dcim.graphql.filters import ( + DeviceRoleFilter, DeviceTypeFilter, LocationFilter, PlatformFilter, RegionFilter, SiteFilter, SiteGroupFilter, + ) + from tenancy.graphql.filters import TenantFilter, TenantGroupFilter + from netbox.graphql.enums import ColorEnum + from netbox.graphql.filter_lookups import IntegerLookup, JSONFilter, StringArrayLookup, TreeNodeFilter + from users.graphql.filters import GroupFilter, UserFilter + from virtualization.graphql.filters import ClusterFilter, ClusterGroupFilter, ClusterTypeFilter + from .enums import * __all__ = ( 'ConfigContextFilter', @@ -21,78 +40,271 @@ __all__ = ( @strawberry_django.filter(models.ConfigContext, lookups=True) -@autotype_decorator(filtersets.ConfigContextFilterSet) -class ConfigContextFilter(BaseFilterMixin): - pass +class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + description: FilterLookup[str] = strawberry_django.filter_field() + is_active: FilterLookup[bool] = strawberry_django.filter_field() + regions: 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() + ) + site_groups: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + site_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + locations: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + device_types: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + roles: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + platforms: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_types: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_groups: Annotated['ClusterGroupFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_groups: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + tenants: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field() + data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ConfigTemplate, lookups=True) -@autotype_decorator(filtersets.ConfigTemplateFilterSet) -class ConfigTemplateFilter(BaseFilterMixin): - pass +class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mime_type: FilterLookup[str] | None = strawberry_django.filter_field() + file_name: FilterLookup[str] | None = strawberry_django.filter_field() + file_extension: FilterLookup[str] | None = strawberry_django.filter_field() + as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.CustomField, lookups=True) -@autotype_decorator(filtersets.CustomFieldFilterSet) -class CustomFieldFilter(BaseFilterMixin): - pass +class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + type: Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + object_types: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + related_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() + label: FilterLookup[str] | None = strawberry_django.filter_field() + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + required: FilterLookup[bool] | None = strawberry_django.filter_field() + unique: FilterLookup[bool] | None = strawberry_django.filter_field() + search_weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + filter_logic: Annotated['CustomFieldFilterLogicEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + default: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + related_object_filter: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_minimum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_maximum: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + validation_regex: FilterLookup[str] | None = strawberry_django.filter_field() + choice_set: Annotated['CustomFieldChoiceSetFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + choice_set_id: ID | None = strawberry_django.filter_field() + ui_visible: Annotated['CustomFieldUIVisibleEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ui_editable: Annotated['CustomFieldUIEditableEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + is_cloneable: FilterLookup[bool] | None = strawberry_django.filter_field() + comments: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True) -@autotype_decorator(filtersets.CustomFieldChoiceSetFilterSet) -class CustomFieldChoiceSetFilter(BaseFilterMixin): - pass +class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + base_choices: Annotated['CustomFieldChoiceSetBaseEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + extra_choices: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + order_alphabetically: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.CustomLink, lookups=True) -@autotype_decorator(filtersets.CustomLinkFilterSet) -class CustomLinkFilter(BaseFilterMixin): - pass +class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + link_text: FilterLookup[str] | None = strawberry_django.filter_field() + link_url: FilterLookup[str] | None = strawberry_django.filter_field() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + group_name: FilterLookup[str] | None = strawberry_django.filter_field() + button_class: Annotated['CustomLinkButtonClassEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + new_window: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ExportTemplate, lookups=True) -@autotype_decorator(filtersets.ExportTemplateFilterSet) -class ExportTemplateFilter(BaseFilterMixin): - pass +class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + template_code: FilterLookup[str] | None = strawberry_django.filter_field() + environment_params: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + mime_type: FilterLookup[str] | None = strawberry_django.filter_field() + file_name: FilterLookup[str] | None = strawberry_django.filter_field() + file_extension: FilterLookup[str] | None = strawberry_django.filter_field() + as_attachment: FilterLookup[bool] | None = strawberry_django.filter_field() @strawberry_django.filter(models.ImageAttachment, lookups=True) -@autotype_decorator(filtersets.ImageAttachmentFilterSet) -class ImageAttachmentFilter(BaseFilterMixin): - pass +class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + image_height: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + image_width: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + name: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.JournalEntry, lookups=True) -@autotype_decorator(filtersets.JournalEntryFilterSet) -class JournalEntryFilter(BaseFilterMixin): - pass +class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_type_id: ID | None = strawberry_django.filter_field() + assigned_object_id: ID | None = strawberry_django.filter_field() + created_by: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + kind: Annotated['JournalEntryKindEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.NotificationGroup, lookups=True) -@autotype_decorator(filtersets.NotificationGroupFilterSet) -class NotificationGroupFilter(BaseFilterMixin): - pass +class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() + users: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.SavedFilter, lookups=True) -@autotype_decorator(filtersets.SavedFilterFilterSet) -class SavedFilterFilter(BaseFilterMixin): - pass +class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | 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() + weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + shared: FilterLookup[bool] | None = strawberry_django.filter_field() + parameters: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Tag, lookups=True) -@autotype_decorator(filtersets.TagFilterSet) -class TagFilter(BaseFilterMixin): - pass +class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin): + color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.Webhook, lookups=True) -@autotype_decorator(filtersets.WebhookFilterSet) -class WebhookFilter(BaseFilterMixin): - pass +class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + payload_url: FilterLookup[str] | None = strawberry_django.filter_field() + http_method: Annotated['WebhookHttpMethodEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + http_content_type: FilterLookup[str] | None = strawberry_django.filter_field() + additional_headers: FilterLookup[str] | None = strawberry_django.filter_field() + body_template: FilterLookup[str] | None = strawberry_django.filter_field() + secret: FilterLookup[str] | None = strawberry_django.filter_field() + ssl_verification: FilterLookup[bool] | None = strawberry_django.filter_field() + ca_file_path: FilterLookup[str] | None = strawberry_django.filter_field() + events: Annotated['EventRuleFilter', strawberry.lazy('extras.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.EventRule, lookups=True) -@autotype_decorator(filtersets.EventRuleFilterSet) -class EventRuleFilter(BaseFilterMixin): - pass +class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() + event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + enabled: FilterLookup[bool] | None = strawberry_django.filter_field() + conditions: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + action_type: Annotated['EventRuleActionEnum', strawberry.lazy('extras.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + action_object_type: FilterLookup[str] | None = strawberry_django.filter_field() + action_object_type_id: ID | None = strawberry_django.filter_field() + action_object_id: ID | None = strawberry_django.filter_field() + action_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + comments: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index a53c7bed3..f4a1a397f 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -8,6 +8,22 @@ from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType from .filters import * +if TYPE_CHECKING: + from core.graphql.types import DataFileType, DataSourceType + from dcim.graphql.types import ( + DeviceRoleType, + DeviceType, + DeviceTypeType, + LocationType, + PlatformType, + RegionType, + SiteGroupType, + SiteType, + ) + from tenancy.graphql.types import TenantGroupType, TenantType + from users.graphql.types import GroupType, UserType + from virtualization.graphql.types import ClusterGroupType, ClusterType, ClusterTypeType, VirtualMachineType + __all__ = ( 'ConfigContextType', 'ConfigTemplateType', @@ -30,12 +46,12 @@ __all__ = ( @strawberry_django.type( models.ConfigContext, fields='__all__', - filters=ConfigContextFilter + filters=ConfigContextFilter, + pagination=True ) class ConfigContextType(ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None - roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]] @@ -54,7 +70,8 @@ class ConfigContextType(ObjectType): @strawberry_django.type( models.ConfigTemplate, fields='__all__', - filters=ConfigTemplateFilter + filters=ConfigTemplateFilter, + pagination=True ) class ConfigTemplateType(TagsMixin, ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None @@ -69,7 +86,8 @@ class ConfigTemplateType(TagsMixin, ObjectType): @strawberry_django.type( models.CustomField, fields='__all__', - filters=CustomFieldFilter + filters=CustomFieldFilter, + pagination=True ) class CustomFieldType(ObjectType): related_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -78,8 +96,9 @@ class CustomFieldType(ObjectType): @strawberry_django.type( models.CustomFieldChoiceSet, - exclude=('extra_choices', ), - filters=CustomFieldChoiceSetFilter + exclude=['extra_choices'], + filters=CustomFieldChoiceSetFilter, + pagination=True ) class CustomFieldChoiceSetType(ObjectType): @@ -90,7 +109,8 @@ class CustomFieldChoiceSetType(ObjectType): @strawberry_django.type( models.CustomLink, fields='__all__', - filters=CustomLinkFilter + filters=CustomLinkFilter, + pagination=True ) class CustomLinkType(ObjectType): pass @@ -99,7 +119,8 @@ class CustomLinkType(ObjectType): @strawberry_django.type( models.ExportTemplate, fields='__all__', - filters=ExportTemplateFilter + filters=ExportTemplateFilter, + pagination=True ) class ExportTemplateType(ObjectType): data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None @@ -109,7 +130,8 @@ class ExportTemplateType(ObjectType): @strawberry_django.type( models.ImageAttachment, fields='__all__', - filters=ImageAttachmentFilter + filters=ImageAttachmentFilter, + pagination=True ) class ImageAttachmentType(BaseObjectType): object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -118,7 +140,8 @@ class ImageAttachmentType(BaseObjectType): @strawberry_django.type( models.JournalEntry, fields='__all__', - filters=JournalEntryFilter + filters=JournalEntryFilter, + pagination=True ) class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): assigned_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None @@ -128,6 +151,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.Notification, # filters=NotificationFilter + pagination=True ) class NotificationType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -135,7 +159,8 @@ class NotificationType(ObjectType): @strawberry_django.type( models.NotificationGroup, - filters=NotificationGroupFilter + filters=NotificationGroupFilter, + pagination=True ) class NotificationGroupType(ObjectType): users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]] @@ -145,7 +170,8 @@ class NotificationGroupType(ObjectType): @strawberry_django.type( models.SavedFilter, exclude=['content_types',], - filters=SavedFilterFilter + filters=SavedFilterFilter, + pagination=True ) class SavedFilterType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -154,6 +180,7 @@ class SavedFilterType(ObjectType): @strawberry_django.type( models.Subscription, # filters=NotificationFilter + pagination=True ) class SubscriptionType(ObjectType): user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None @@ -162,7 +189,8 @@ class SubscriptionType(ObjectType): @strawberry_django.type( models.Tag, exclude=['extras_taggeditem_items', ], - filters=TagFilter + filters=TagFilter, + pagination=True ) class TagType(ObjectType): color: str @@ -173,7 +201,8 @@ class TagType(ObjectType): @strawberry_django.type( models.Webhook, exclude=['content_types',], - filters=WebhookFilter + filters=WebhookFilter, + pagination=True ) class WebhookType(OrganizationalObjectType): pass @@ -182,7 +211,8 @@ class WebhookType(OrganizationalObjectType): @strawberry_django.type( models.EventRule, exclude=['content_types',], - filters=EventRuleFilter + filters=EventRuleFilter, + pagination=True ) class EventRuleType(OrganizationalObjectType): action_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index ade486fc0..ade20a118 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -11,6 +11,7 @@ from packaging import version from core.models import Job, ObjectChange from netbox.config import Config +from utilities.proxy import resolve_proxies class Command(BaseCommand): @@ -107,7 +108,7 @@ class Command(BaseCommand): response = requests.get( url=settings.RELEASE_CHECK_URL, headers=headers, - proxies=settings.HTTP_PROXIES + proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL) ) response.raise_for_status() diff --git a/netbox/extras/migrations/0002_squashed_0059.py b/netbox/extras/migrations/0002_squashed_0059.py index b664b286e..3aa7644fd 100644 --- a/netbox/extras/migrations/0002_squashed_0059.py +++ b/netbox/extras/migrations/0002_squashed_0059.py @@ -3,10 +3,10 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('extras', '0001_initial'), - ('virtualization', '0001_virtualization'), - ('tenancy', '0001_initial'), + ('dcim', '0002_squashed'), + ('extras', '0001_squashed'), + ('virtualization', '0001_squashed_0022'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ diff --git a/netbox/extras/migrations/0060_squashed_0086.py b/netbox/extras/migrations/0060_squashed_0086.py index 3bde7480f..2e4437c6b 100644 --- a/netbox/extras/migrations/0060_squashed_0086.py +++ b/netbox/extras/migrations/0060_squashed_0086.py @@ -45,13 +45,13 @@ class Migration(migrations.Migration): dependencies = [ ('virtualization', '0001_squashed_0022'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0001_initial'), + ('core', '0001_squashed_0005'), ('contenttypes', '0002_remove_content_type_name'), - ('wireless', '0008_wirelesslan_status'), - ('dcim', '0166_virtualdevicecontext'), - ('tenancy', '0009_standardize_description_comments'), - ('extras', '0059_exporttemplate_as_attachment'), - ('circuits', '0041_standardize_description_comments'), + ('wireless', '0001_squashed_0008'), + ('dcim', '0160_squashed_0166'), + ('tenancy', '0001_squashed_0012'), + ('extras', '0002_squashed_0059'), + ('circuits', '0038_squashed_0042'), ] operations = [ diff --git a/netbox/extras/migrations/0087_squashed_0098.py b/netbox/extras/migrations/0087_squashed_0098.py index 839f4cbe4..21a6116b7 100644 --- a/netbox/extras/migrations/0087_squashed_0098.py +++ b/netbox/extras/migrations/0087_squashed_0098.py @@ -26,9 +26,9 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('extras', '0086_configtemplate'), + ('extras', '0060_squashed_0086'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('core', '0002_managedfile'), + ('core', '0001_squashed_0005'), ] operations = [ diff --git a/netbox/extras/migrations/0099_cachedvalue_ordering.py b/netbox/extras/migrations/0099_cachedvalue_ordering.py index 36b91d59b..d3ddc5533 100644 --- a/netbox/extras/migrations/0099_cachedvalue_ordering.py +++ b/netbox/extras/migrations/0099_cachedvalue_ordering.py @@ -5,7 +5,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('extras', '0087_squashed_0098'), ] operations = [ diff --git a/netbox/extras/migrations/0123_remove_staging.py b/netbox/extras/migrations/0123_remove_staging.py new file mode 100644 index 000000000..643cd912d --- /dev/null +++ b/netbox/extras/migrations/0123_remove_staging.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.5 on 2025-02-20 19:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0122_charfield_null_choices'), + ] + + operations = [ + migrations.RemoveField( + model_name='stagedchange', + name='branch', + ), + migrations.RemoveField( + model_name='stagedchange', + name='object_type', + ), + migrations.DeleteModel( + name='Branch', + ), + migrations.DeleteModel( + name='StagedChange', + ), + ] diff --git a/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py new file mode 100644 index 000000000..759ad1595 --- /dev/null +++ b/netbox/extras/migrations/0124_alter_tag_options_tag_weight.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0123_remove_staging'), + ] + + operations = [ + migrations.AlterModelOptions( + name='tag', + options={'ordering': ('weight', 'name')}, + ), + migrations.AddField( + model_name='tag', + name='weight', + field=models.PositiveSmallIntegerField(default=0), + ), + ] diff --git a/netbox/extras/migrations/0125_exporttemplate_file_name.py b/netbox/extras/migrations/0125_exporttemplate_file_name.py new file mode 100644 index 000000000..2c8ac118b --- /dev/null +++ b/netbox/extras/migrations/0125_exporttemplate_file_name.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0124_alter_tag_options_tag_weight'), + ] + + operations = [ + migrations.AddField( + model_name='exporttemplate', + name='file_name', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py b/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py new file mode 100644 index 000000000..6d5aad62e --- /dev/null +++ b/netbox/extras/migrations/0126_configtemplate_as_attachment_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2b1 on 2025-04-04 20:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0125_exporttemplate_file_name'), + ] + + operations = [ + migrations.AddField( + model_name='configtemplate', + name='as_attachment', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='configtemplate', + name='file_extension', + field=models.CharField(blank=True, max_length=15), + ), + migrations.AddField( + model_name='configtemplate', + name='file_name', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='configtemplate', + name='mime_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='exporttemplate', + name='environment_params', + field=models.JSONField(blank=True, default=dict, null=True), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index e85721034..f214b1268 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -5,5 +5,4 @@ from .models import * from .notifications import * from .scripts import * from .search import * -from .staging import * from .tags import * diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 6b52d4c02..204cc04f9 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -4,16 +4,13 @@ from django.core.validators import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from jinja2.loaders import BaseLoader -from jinja2.sandbox import SandboxedEnvironment +from extras.models.mixins import RenderTemplateMixin from extras.querysets import ConfigContextQuerySet -from netbox.config import get_config from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin from netbox.registry import registry from utilities.data import deepmerge -from utilities.jinja2 import DataFileLoader __all__ = ( 'ConfigContext', @@ -210,7 +207,9 @@ class ConfigContextModel(models.Model): # Config templates # -class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): +class ConfigTemplate( + RenderTemplateMixin, SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel +): name = models.CharField( verbose_name=_('name'), max_length=100 @@ -220,20 +219,6 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta max_length=200, blank=True ) - template_code = models.TextField( - verbose_name=_('template code'), - help_text=_('Jinja2 template code.') - ) - environment_params = models.JSONField( - verbose_name=_('environment parameters'), - blank=True, - null=True, - default=dict, - help_text=_( - 'Any additional parameters' - ' to pass when constructing the Jinja2 environment.' - ) - ) class Meta: ordering = ('name',) @@ -253,13 +238,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta self.template_code = self.data_file.data_as_string sync_data.alters_data = True - def render(self, context=None): - """ - Render the contents of the template. - """ + def get_context(self, context=None, queryset=None): _context = dict() - - # Populate the default template context with NetBox model classes, namespaced by app for app, model_names in registry['models'].items(): _context.setdefault(app, {}) for model_name in model_names: @@ -269,37 +249,8 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta except LookupError: pass - # Add the provided context data, if any + # Apply the provided context data, if any if context is not None: _context.update(context) - # Initialize the Jinja2 environment and instantiate the Template - environment = self._get_environment() - if self.data_file: - template = environment.get_template(self.data_file.path) - else: - template = environment.from_string(self.template_code) - output = template.render(**_context) - - # Replace CRLF-style line terminators - return output.replace('\r\n', '\n') - - def _get_environment(self): - """ - Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate. - """ - # Initialize the template loader & cache the base template code (if applicable) - if self.data_file: - loader = DataFileLoader(data_source=self.data_source) - loader.cache_templates({ - self.data_file.path: self.template_code - }) - else: - loader = BaseLoader() - - # Initialize the environment - env_params = self.environment_params or {} - environment = SandboxedEnvironment(loader=loader, **env_params) - environment.filters.update(get_config().JINJA2_FILTERS) - - return environment + return _context diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 0950324c8..3a7273f93 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -1,11 +1,40 @@ +import importlib.abc +import importlib.util import os -from importlib.machinery import SourceFileLoader +import sys +from django.core.files.storage import storages +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponse + +from extras.constants import DEFAULT_MIME_TYPE +from extras.utils import filename_from_model, filename_from_object +from utilities.jinja2 import render_jinja2 + __all__ = ( 'PythonModuleMixin', + 'RenderTemplateMixin', ) +class CustomStoragesLoader(importlib.abc.Loader): + """ + Custom loader for exec_module to use django-storages instead of the file system. + """ + def __init__(self, filename): + self.filename = filename + + def create_module(self, spec): + return None # Use default module creation + + def exec_module(self, module): + storage = storages.create_storage(storages.backends["scripts"]) + with storage.open(self.filename, 'rb') as f: + code = f.read() + exec(code, module.__dict__) + + class PythonModuleMixin: def get_jobs(self, name): @@ -33,6 +62,99 @@ class PythonModuleMixin: return name def get_module(self): - loader = SourceFileLoader(self.python_name, self.full_path) - module = loader.load_module() + """ + Load the module using importlib, but use a custom loader to use django-storages + instead of the file system. + """ + spec = importlib.util.spec_from_file_location(self.python_name, self.name) + if spec is None: + raise ModuleNotFoundError(f"Could not find module: {self.python_name}") + loader = CustomStoragesLoader(self.name) + module = importlib.util.module_from_spec(spec) + sys.modules[self.python_name] = module + loader.exec_module(module) + return module + + +class RenderTemplateMixin(models.Model): + """ + Enables support for rendering templates. + """ + template_code = models.TextField( + verbose_name=_('template code'), + help_text=_('Jinja template code.') + ) + environment_params = models.JSONField( + verbose_name=_('environment parameters'), + blank=True, + null=True, + default=dict, + help_text=_( + 'Any additional parameters to pass when constructing the Jinja environment' + ).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment') + ) + mime_type = models.CharField( + max_length=50, + blank=True, + verbose_name=_('MIME type'), + help_text=_('Defaults to {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 '.'. render() provides the following context data: + The `models` attribute on the class defines the which specific model detail pages this class renders content + for. It should be defined as a list of strings in the following form: + + models = ['.', '.'] + + If `models` is left as None, the extension will render for _all_ models. + + The `render()` method provides the following context data: * object - The object being viewed (object views only) * model - The type of object being viewed (list views only) @@ -21,7 +27,6 @@ class PluginTemplateExtension: * config - Plugin-specific configuration parameters """ models = None - model = None # Deprecated; use `models` instead def __init__(self, context): self.context = context @@ -42,6 +47,13 @@ class PluginTemplateExtension: # Global methods # + def head(self): + """ + HTML returned by this method will be inserted in the page's `` block. This may be useful e.g. for + including additional Javascript or CSS resources. + """ + raise NotImplementedError + def navbar(self): """ Content that will be rendered inside the top navigation menu. Content should be returned as an HTML diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a17bb7730..c453817d0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -9,12 +9,15 @@ import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ +from core.exceptions import IncompatiblePluginError from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig from netbox.registry import registry +import storages.utils # type: ignore from utilities.release import load_release_data from utilities.string import trailing_slash @@ -50,14 +53,18 @@ except ModuleNotFoundError as e: ) raise -# Check for missing required configuration parameters -for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'): +# Check for missing/conflicting required configuration parameters +for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'): if not hasattr(configuration, parameter): raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.") +if not hasattr(configuration, 'DATABASE') and not hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("The database configuration must be defined using DATABASE or DATABASES.") +elif hasattr(configuration, 'DATABASE') and hasattr(configuration, 'DATABASES'): + raise ImproperlyConfigured("DATABASE and DATABASES may not be set together. The use of DATABASES is encouraged.") # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) +ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', False) ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', [ { @@ -81,7 +88,9 @@ CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) -DATABASE = getattr(configuration, 'DATABASE') # Required +DATABASE = getattr(configuration, 'DATABASE', None) # Legacy DB definition +DATABASE_ROUTERS = getattr(configuration, 'DATABASE_ROUTERS', []) +DATABASES = getattr(configuration, 'DATABASES', {'default': DATABASE}) DEBUG = getattr(configuration, 'DEBUG', False) DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None) DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { @@ -116,7 +125,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440) GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10) -HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) +HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {}) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False) JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) @@ -126,11 +135,14 @@ LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) +LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False) LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home') MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) +PLUGINS_CATALOG_CONFIG = getattr(configuration, 'PLUGINS_CATALOG_CONFIG', {}) +PROXY_ROUTERS = getattr(configuration, 'PROXY_ROUTERS', ['utilities.proxy.DefaultProxyRouter']) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) REDIS = getattr(configuration, 'REDIS') # Required RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) @@ -174,7 +186,8 @@ SESSION_COOKIE_PATH = CSRF_COOKIE_PATH SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) -STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) +STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', None) +STORAGES = getattr(configuration, 'STORAGES', {}) TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True) @@ -201,83 +214,92 @@ if RELEASE_CHECK_URL: "RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox" ) +# Validate configured proxy routers +for path in PROXY_ROUTERS: + if type(path) is str: + try: + import_string(path) + except ImportError: + raise ImproperlyConfigured(f"Invalid path in PROXY_ROUTERS: {path}") + # # Database # -# Set the database engine -if 'ENGINE' not in DATABASE: - if METRICS_ENABLED: - DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'}) - else: - DATABASE.update({'ENGINE': 'django.db.backends.postgresql'}) +# Verify that a default database has been configured +if 'default' not in DATABASES: + raise ImproperlyConfigured("No default database has been configured.") -# Define the DATABASES setting for Django -DATABASES = { - 'default': DATABASE, -} +# Set the database engine +if 'ENGINE' not in DATABASES['default']: + DATABASES['default'].update({ + 'ENGINE': 'django_prometheus.db.backends.postgresql' if METRICS_ENABLED else 'django.db.backends.postgresql' + }) # # Storage backend # +if STORAGE_BACKEND is not None: + if not STORAGES: + raise ImproperlyConfigured( + "STORAGE_BACKEND and STORAGES are both set, remove the deprecated STORAGE_BACKEND setting." + ) + else: + warnings.warn( + "STORAGE_BACKEND is deprecated, use the new STORAGES setting instead." + ) + +if STORAGE_CONFIG is not None: + warnings.warn( + "STORAGE_CONFIG is deprecated, use the new STORAGES setting instead." + ) + # Default STORAGES for Django -STORAGES = { +DEFAULT_STORAGES = { "default": { "BACKEND": "django.core.files.storage.FileSystemStorage", }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, + "scripts": { + "BACKEND": "extras.storage.ScriptFileSystemStorage", + }, } +STORAGES = DEFAULT_STORAGES | STORAGES +# TODO: This code is deprecated and needs to be removed in the future if STORAGE_BACKEND is not None: STORAGES['default']['BACKEND'] = STORAGE_BACKEND - # django-storages - if STORAGE_BACKEND.startswith('storages.'): - try: - import storages.utils # type: ignore - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'storages': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be " - f"installed by running 'pip install django-storages'." - ) - raise e +# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG +if STORAGE_CONFIG is not None: + def _setting(name, default=None): + if name in STORAGE_CONFIG: + return STORAGE_CONFIG[name] + return globals().get(name, default) + storages.utils.setting = _setting - # Monkey-patch django-storages to fetch settings from STORAGE_CONFIG - def _setting(name, default=None): - if name in STORAGE_CONFIG: - return STORAGE_CONFIG[name] - return globals().get(name, default) - storages.utils.setting = _setting - - # django-storage-swift - elif STORAGE_BACKEND == 'swift.storage.SwiftStorage': - try: - import swift.utils # noqa: F401 - except ModuleNotFoundError as e: - if getattr(e, 'name') == 'swift': - raise ImproperlyConfigured( - f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " - "It can be installed by running 'pip install django-storage-swift'." - ) - raise e - - # Load all SWIFT_* settings from the user configuration - for param, value in STORAGE_CONFIG.items(): - if param.startswith('SWIFT_'): - globals()[param] = value - -if STORAGE_CONFIG and STORAGE_BACKEND is None: - warnings.warn( - "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " - "ignored." - ) +# django-storage-swift +if STORAGE_BACKEND == 'swift.storage.SwiftStorage': + try: + import swift.utils # noqa: F401 + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'swift': + raise ImproperlyConfigured( + f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " + "It can be installed by running 'pip install django-storage-swift'." + ) + raise e + # Load all SWIFT_* settings from the user configuration + for param, value in STORAGE_CONFIG.items(): + if param.startswith('SWIFT_'): + globals()[param] = value +# TODO: End of deprecated code # # Redis @@ -577,6 +599,7 @@ if SENTRY_ENABLED: sample_rate=SENTRY_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, send_default_pii=SENTRY_SEND_DEFAULT_PII, + # TODO: Support proxy routing http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None ) @@ -776,7 +799,7 @@ LOCALE_PATHS = ( STRAWBERRY_DJANGO = { "DEFAULT_PK_FIELD_NAME": "id", "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, - "USE_DEPRECATED_FILTERS": True, + "PAGINATION_DEFAULT_LIMIT": 100, } # @@ -811,6 +834,15 @@ for plugin_name in PLUGINS: f"__init__.py file and point to the PluginConfig subclass." ) + # Validate version compatibility and user-provided configuration settings and assign defaults + if plugin_name not in PLUGINS_CONFIG: + PLUGINS_CONFIG[plugin_name] = {} + try: + plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) + except IncompatiblePluginError as e: + warnings.warn(f'Unable to load plugin {plugin_name}: {e}') + continue + # Register the plugin as installed successfully registry['plugins']['installed'].append(plugin_name) @@ -843,11 +875,6 @@ for plugin_name in PLUGINS: sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) INSTALLED_APPS = list(sorted_apps) - # Validate user-provided configuration settings and assign defaults - if plugin_name not in PLUGINS_CONFIG: - PLUGINS_CONFIG[plugin_name] = {} - plugin_config.validate(PLUGINS_CONFIG[plugin_name], RELEASE.version) - # Add middleware plugin_middleware = plugin_config.middleware if plugin_middleware and type(plugin_middleware) in (list, tuple): @@ -869,6 +896,7 @@ for plugin_name in PLUGINS: else: raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple") + # UNSUPPORTED FUNCTIONALITY: Import any local overrides. try: from .local_settings import * diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py deleted file mode 100644 index e6b946403..000000000 --- a/netbox/netbox/staging.py +++ /dev/null @@ -1,148 +0,0 @@ -import logging - -from django.contrib.contenttypes.models import ContentType -from django.db import transaction -from django.db.models.signals import m2m_changed, pre_delete, post_save - -from extras.choices import ChangeActionChoices -from extras.models import StagedChange -from utilities.serialization import serialize_object - -logger = logging.getLogger('netbox.staging') - - -class checkout: - """ - Context manager for staging changes to NetBox objects. Staged changes are saved out-of-band - (as Change instances) for application at a later time, without modifying the production - database. - - branch = Branch.objects.create(name='my-branch') - with checkout(branch): - # All changes made herein will be rolled back and stored for later - - Note that invoking the context disabled transaction autocommit to facilitate manual rollbacks, - and restores its original value upon exit. - """ - def __init__(self, branch): - self.branch = branch - self.queue = {} - - def __enter__(self): - - # Disable autocommit to effect a new transaction - logger.debug(f"Entering transaction for {self.branch}") - self._autocommit = transaction.get_autocommit() - transaction.set_autocommit(False) - - # Apply any existing Changes assigned to this Branch - staged_changes = self.branch.staged_changes.all() - if change_count := staged_changes.count(): - logger.debug(f"Applying {change_count} pre-staged changes...") - for change in staged_changes: - change.apply() - else: - logger.debug("No pre-staged changes found") - - # Connect signal handlers - logger.debug("Connecting signal handlers") - post_save.connect(self.post_save_handler) - m2m_changed.connect(self.post_save_handler) - pre_delete.connect(self.pre_delete_handler) - - def __exit__(self, exc_type, exc_val, exc_tb): - - # Disconnect signal handlers - logger.debug("Disconnecting signal handlers") - post_save.disconnect(self.post_save_handler) - m2m_changed.disconnect(self.post_save_handler) - pre_delete.disconnect(self.pre_delete_handler) - - # Roll back the transaction to return the database to its original state - logger.debug("Rolling back database transaction") - transaction.rollback() - logger.debug(f"Restoring autocommit state ({self._autocommit})") - transaction.set_autocommit(self._autocommit) - - # Process queued changes - self.process_queue() - - # - # Queuing - # - - @staticmethod - def get_key_for_instance(instance): - return ContentType.objects.get_for_model(instance), instance.pk - - def process_queue(self): - """ - Create Change instances for all actions stored in the queue. - """ - if not self.queue: - logger.debug("No queued changes; aborting") - return - logger.debug(f"Processing {len(self.queue)} queued changes") - - # Iterate through the in-memory queue, creating Change instances - changes = [] - for key, change in self.queue.items(): - logger.debug(f' {key}: {change}') - object_type, pk = key - action, data = change - - changes.append(StagedChange( - branch=self.branch, - action=action, - object_type=object_type, - object_id=pk, - data=data - )) - - # Save all Change instances to the database - StagedChange.objects.bulk_create(changes) - - # - # Signal handlers - # - - def post_save_handler(self, sender, instance, **kwargs): - """ - Hooks to the post_save signal when a branch is active to queue create and update actions. - """ - key = self.get_key_for_instance(instance) - object_type = instance._meta.verbose_name - - # Creating a new object - if kwargs.get('created'): - logger.debug(f"[{self.branch}] Staging creation of {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (ChangeActionChoices.ACTION_CREATE, data) - return - - # Ignore pre_* many-to-many actions - if 'action' in kwargs and kwargs['action'] not in ('post_add', 'post_remove', 'post_clear'): - return - - # Object has already been created/updated in the queue; update its queued representation - if key in self.queue: - logger.debug(f"[{self.branch}] Updating staged value for {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (self.queue[key][0], data) - return - - # Modifying an existing object for the first time - logger.debug(f"[{self.branch}] Staging changes to {object_type} {instance} (PK: {instance.pk})") - data = serialize_object(instance, resolve_tags=False) - self.queue[key] = (ChangeActionChoices.ACTION_UPDATE, data) - - def pre_delete_handler(self, sender, instance, **kwargs): - """ - Hooks to the pre_delete signal when a branch is active to queue delete actions. - """ - key = self.get_key_for_instance(instance) - object_type = instance._meta.verbose_name - - # Delete an existing object - logger.debug(f"[{self.branch}] Staging deletion of {object_type} {instance} (PK: {instance.pk})") - self.queue[key] = (ChangeActionChoices.ACTION_DELETE, None) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index cf6e1f133..f0e55176d 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -35,6 +35,7 @@ __all__ = ( 'ContentTypesColumn', 'CustomFieldColumn', 'CustomLinkColumn', + 'DictColumn', 'DistanceColumn', 'DurationColumn', 'LinkedCountColumn', @@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn): def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs): super().__init__(template_code=template_code, order_by=order_by, **kwargs) + + +class DictColumn(tables.Column): + """ + Render a dictionary of data in a simple key: value format, one pair per line. + """ + def render(self, value): + output = '
'.join([ + f'{escape(k)}: {escape(v)}' for k, v in value.items() + ]) + return mark_safe(output) diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py index e9a6b9da1..e962594d4 100644 --- a/netbox/netbox/tests/dummy_plugin/template_content.py +++ b/netbox/netbox/tests/dummy_plugin/template_content.py @@ -3,6 +3,9 @@ from netbox.plugins.templates import PluginTemplateExtension class GlobalContent(PluginTemplateExtension): + def head(self): + return "" + def navbar(self): return "GLOBAL CONTENT - NAVBAR" diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index b04d42d24..ca231526f 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -99,8 +99,8 @@ class GraphQLAPITestCase(APITestCase): # Test OR logic query = """{ location_list( filters: { - status: \"""" + LocationStatusChoices.STATUS_PLANNED + """\", - OR: {status: \"""" + LocationStatusChoices.STATUS_STAGING + """\"} + status: STATUS_PLANNED, + OR: {status: STATUS_STAGING} }) { id site {id} } diff --git a/netbox/netbox/tests/test_staging.py b/netbox/netbox/tests/test_staging.py deleted file mode 100644 index 0a73b2987..000000000 --- a/netbox/netbox/tests/test_staging.py +++ /dev/null @@ -1,216 +0,0 @@ -from django.db.models.signals import post_save -from django.test import TransactionTestCase - -from circuits.models import Provider, Circuit, CircuitType -from extras.choices import ChangeActionChoices -from extras.models import Branch, StagedChange, Tag -from ipam.models import ASN, RIR -from netbox.search.backends import search_backend -from netbox.staging import checkout -from utilities.testing import create_tags - - -class StagingTestCase(TransactionTestCase): - - def setUp(self): - # Disconnect search backend to avoid issues with cached ObjectTypes being deleted - # from the database upon transaction rollback - post_save.disconnect(search_backend.caching_handler) - - create_tags('Alpha', 'Bravo', 'Charlie') - - rir = RIR.objects.create(name='RIR 1', slug='rir-1') - asns = ( - ASN(asn=65001, rir=rir), - ASN(asn=65002, rir=rir), - ASN(asn=65003, rir=rir), - ) - ASN.objects.bulk_create(asns) - - providers = ( - Provider(name='Provider A', slug='provider-a'), - Provider(name='Provider B', slug='provider-b'), - Provider(name='Provider C', slug='provider-c'), - ) - Provider.objects.bulk_create(providers) - - circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - - Circuit.objects.bulk_create(( - Circuit(provider=providers[0], cid='Circuit A1', type=circuit_type), - Circuit(provider=providers[0], cid='Circuit A2', type=circuit_type), - Circuit(provider=providers[0], cid='Circuit A3', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B1', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B2', type=circuit_type), - Circuit(provider=providers[1], cid='Circuit B3', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C1', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C2', type=circuit_type), - Circuit(provider=providers[2], cid='Circuit C3', type=circuit_type), - )) - - def test_object_creation(self): - branch = Branch.objects.create(name='Branch 1') - tags = Tag.objects.all() - asns = ASN.objects.all() - - with checkout(branch): - provider = Provider.objects.create(name='Provider D', slug='provider-d') - provider.asns.set(asns) - circuit = Circuit.objects.create(provider=provider, cid='Circuit D1', type=CircuitType.objects.first()) - circuit.tags.set(tags) - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 4) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 10) - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(StagedChange.objects.count(), 5) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 4) - self.assertEqual(Circuit.objects.count(), 10) - provider = Provider.objects.get(name='Provider D') - self.assertListEqual(list(provider.asns.all()), list(asns)) - circuit = Circuit.objects.get(cid='Circuit D1') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 4) - self.assertEqual(Circuit.objects.count(), 10) - provider = Provider.objects.get(name='Provider D') - self.assertListEqual(list(provider.asns.all()), list(asns)) - circuit = Circuit.objects.get(cid='Circuit D1') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_object_modification(self): - branch = Branch.objects.create(name='Branch 1') - tags = Tag.objects.all() - asns = ASN.objects.all() - - with checkout(branch): - provider = Provider.objects.get(name='Provider A') - provider.name = 'Provider X' - provider.save() - provider.asns.set(asns) - circuit = Circuit.objects.get(cid='Circuit A1') - circuit.cid = 'Circuit X' - circuit.save() - circuit.tags.set(tags) - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(Circuit.objects.get(pk=circuit.pk).cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider A') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), []) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit A1') - self.assertListEqual(list(circuit.tags.all()), []) - self.assertEqual(StagedChange.objects.count(), 5) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Provider.objects.get(pk=provider.pk).name, 'Provider X') - provider = Provider.objects.get(pk=provider.pk) - self.assertListEqual(list(provider.asns.all()), list(asns)) - self.assertEqual(Circuit.objects.count(), 9) - circuit = Circuit.objects.get(pk=circuit.pk) - self.assertEqual(circuit.cid, 'Circuit X') - self.assertListEqual(list(circuit.tags.all()), list(tags)) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_object_deletion(self): - branch = Branch.objects.create(name='Branch 1') - - with checkout(branch): - provider = Provider.objects.get(name='Provider A') - provider.circuits.all().delete() - provider.delete() - - # Sanity-checking - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - - # Verify that changes have been rolled back after exiting the context - self.assertEqual(Provider.objects.count(), 3) - self.assertEqual(Circuit.objects.count(), 9) - self.assertEqual(StagedChange.objects.count(), 4) - - # Verify that changes are replayed upon entering the context - with checkout(branch): - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - - # Verify that changes are applied and deleted upon branch merge - branch.merge() - self.assertEqual(Provider.objects.count(), 2) - self.assertEqual(Circuit.objects.count(), 6) - self.assertEqual(StagedChange.objects.count(), 0) - - def test_exit_enter_context(self): - branch = Branch.objects.create(name='Branch 1') - - with checkout(branch): - - # Create a new object - provider = Provider.objects.create(name='Provider D', slug='provider-d') - provider.save() - - # Check that a create Change was recorded - self.assertEqual(StagedChange.objects.count(), 1) - change = StagedChange.objects.first() - self.assertEqual(change.action, ChangeActionChoices.ACTION_CREATE) - self.assertEqual(change.data['name'], provider.name) - - with checkout(branch): - - # Update the staged object - provider = Provider.objects.get(name='Provider D') - provider.comments = 'New comments' - provider.save() - - # Check that a second Change object has been created for the object - self.assertEqual(StagedChange.objects.count(), 2) - change = StagedChange.objects.last() - self.assertEqual(change.action, ChangeActionChoices.ACTION_UPDATE) - self.assertEqual(change.data['name'], provider.name) - self.assertEqual(change.data['comments'], provider.comments) - - with checkout(branch): - - # Delete the staged object - provider = Provider.objects.get(name='Provider D') - provider.delete() - - # Check that a third Change has recorded the object's deletion - self.assertEqual(StagedChange.objects.count(), 3) - change = StagedChange.objects.last() - self.assertEqual(change.action, ChangeActionChoices.ACTION_DELETE) - self.assertIsNone(change.data) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 88857ad54..447e2a6c5 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -107,7 +107,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): request: The current request """ try: - return template.render_to_response(self.queryset) + return template.render_to_response(queryset=self.queryset) except Exception as e: messages.error( request, diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 1e17d5354..63bbc86a5 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -12,13 +12,19 @@ from core.tables import JobTable, ObjectChangeTable from extras.forms import JournalEntryForm from extras.models import JournalEntry from extras.tables import JournalEntryTable +from tenancy.models import ContactAssignment +from tenancy.tables import ContactAssignmentTable +from tenancy.filtersets import ContactAssignmentFilterSet +from tenancy.forms import ContactAssignmentFilterForm from utilities.permissions import get_permission_for_model from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab from .base import BaseMultiObjectView +from .object_views import ObjectChildrenView __all__ = ( 'BulkSyncDataView', 'ObjectChangeLogView', + 'ObjectContactsView', 'ObjectJobsView', 'ObjectJournalView', 'ObjectSyncDataView', @@ -244,3 +250,25 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView): )) return redirect(self.get_return_url(request)) + + +class ObjectContactsView(ObjectChildrenView): + child_model = ContactAssignment + table = ContactAssignmentTable + filterset = ContactAssignmentFilterSet + filterset_form = ContactAssignmentFilterForm + template_name = 'tenancy/object_contacts.html' + tab = ViewTab( + label=_('Contacts'), + badge=lambda obj: obj.get_contacts().count(), + permission='tenancy.view_contactassignment', + weight=5000 + ) + + def dispatch(self, request, *args, **kwargs): + model = kwargs.pop('model') + self.queryset = model.objects.all() + return super().dispatch(request, *args, **kwargs) + + def get_children(self, request, parent): + return parent.get_contacts().restrict(request.user, 'view').order_by('priority', 'contact', 'role') diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 2cb549a0d..5dbcf97cf 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 7e516f7f4..c93cdc4c4 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index a8b2c9569..72804c765 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 361af0112..f3be85c76 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -1,6 +1,6 @@ { "name": "netbox", - "version": "4.1.0", + "version": "4.3.0", "main": "dist/netbox.js", "license": "Apache-2.0", "private": true, @@ -23,12 +23,12 @@ }, "dependencies": { "@mdi/font": "7.4.47", - "@tabler/core": "1.0.0-beta21", + "@tabler/core": "1.0.0", "bootstrap": "5.3.3", "clipboard": "2.0.11", "flatpickr": "4.6.13", "gridstack": "11.3.0", - "htmx.org": "1.9.12", + "htmx.org": "2.0.4", "query-string": "9.1.1", "sass": "1.83.4", "tom-select": "2.4.2", @@ -53,5 +53,6 @@ }, "resolutions": { "@types/bootstrap/**/@popperjs/core": "^2.11.6" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/netbox/project-static/styles/_variables.scss b/netbox/project-static/styles/_variables.scss index 33b144532..c493149dd 100644 --- a/netbox/project-static/styles/_variables.scss +++ b/netbox/project-static/styles/_variables.scss @@ -2,7 +2,6 @@ // Set base fonts $font-family-sans-serif: 'Inter', system-ui, sans-serif; -// See https://github.com/tabler/tabler/issues/1812 $font-family-monospace: 'Roboto Mono'; // Set the navigation sidebar width @@ -16,9 +15,6 @@ $btn-padding-y: 0.25rem; $table-cell-padding-x: 0.5rem; $table-cell-padding-y: 0.5rem; -// Fix Tabler bug #1694 in 1.0.0-beta20 -$hover-bg: rgba(var(--tblr-secondary-rgb), 0.08); - // Ensure active nav-pill has a background color in dark mode $nav-pills-link-active-bg: rgba(var(--tblr-secondary-rgb), 0.15); diff --git a/netbox/project-static/styles/transitional/_navigation.scss b/netbox/project-static/styles/transitional/_navigation.scss index 67aa19935..d31f1cc82 100644 --- a/netbox/project-static/styles/transitional/_navigation.scss +++ b/netbox/project-static/styles/transitional/_navigation.scss @@ -8,8 +8,8 @@ // Adjust hover color & style for menu items .navbar-collapse { - .nav-link-icon { - color: var(--tblr-nav-link-color) !important; + .nav-link-icon, .nav-link-title { + color: $rich-black; } .text-secondary { color: $dark-teal !important; @@ -26,8 +26,8 @@ visibility: hidden; } - // Style menu item hover state - &:hover { + // Style menu item hover/active state + &:hover, &.active { background-color: var(--tblr-navbar-active-bg); a { text-decoration: none; @@ -37,17 +37,6 @@ } } - // Style active menu item - &.active { - background-color: var(--tblr-navbar-active-bg); - a { - color: $rich-black; - } - .btn-group { - visibility: visible; - } - } - } } } @@ -109,22 +98,17 @@ body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg { border-color: $bright-teal !important; } + .nav-link-title, .nav-link-icon { + color: white !important; + } + // Adjust hover color & style for menu items .dropdown-item { a { color: white !important; } - &.active { + &.active, &:hover { background-color: $navbar-dark-active-bg !important; - a { - color: white !important; - } - } - &:hover { - background-color: $navbar-dark-active-bg !important; - } - .nav-link-title { - color: white !important; } } .text-secondary { diff --git a/netbox/project-static/styles/transitional/_tables.scss b/netbox/project-static/styles/transitional/_tables.scss index 4094631ca..77ce925c1 100644 --- a/netbox/project-static/styles/transitional/_tables.scss +++ b/netbox/project-static/styles/transitional/_tables.scss @@ -1,3 +1,8 @@ +// Reduce column heading font size +.table thead th { + font-size: 0.625rem; +} + // Object list tables table.object-list { diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 92e7e7bd1..c1768aa0c 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -759,19 +759,19 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@tabler/core@1.0.0-beta21": - version "1.0.0-beta21" - resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.0.0-beta21.tgz#cd10d7648b3b7b31927a430fd776d3304e796403" - integrity sha512-9ZKu38BScc0eHruhX/SlVDSiXenBFSgBp2WDq6orkuC8J/1yutKDt7CdXuJpBwkiADEk5yqYV31Ku+CnhwOc3Q== +"@tabler/core@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.0.0.tgz#08736378108663b5893a31ad462be7d12e64be67" + integrity sha512-uFmv6f8TAaW2JaGwzjT1LfK+TjmBQSTCoznCMdV5uur4cv4TtJlV8Hh1Beu55YX0svMtOQ0Xts7tYv/+qBEcfA== dependencies: "@popperjs/core" "^2.11.8" - "@tabler/icons" "^3.14.0" + "@tabler/icons" "^3.29.0" bootstrap "5.3.3" -"@tabler/icons@^3.14.0": - version "3.16.0" - resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.16.0.tgz#d618670b80163925a31a6c2290e8775f6058d81a" - integrity sha512-GU7MSx4uQEr55BmyON6hD/QYTl6k1v0YlRhM91gBWDoKAbyCt6QIYw7rpJ/ecdh5zrHaTOJKPenZ4+luoutwFA== +"@tabler/icons@^3.29.0": + version "3.30.0" + resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.30.0.tgz#4f80f52cc6355b440a4ee0dadd4c3e3775e50663" + integrity sha512-c8OKLM48l00u9TFbh2qhSODMONIzML8ajtCyq95rW8vzkWcBrKRPM61tdkThz2j4kd5u17srPGIjqdeRUZdfdw== "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.0" @@ -1962,10 +1962,10 @@ hey-listen@^1.0.8: resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== -htmx.org@1.9.12: - version "1.9.12" - resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.9.12.tgz#1c5bc7fb4d3eb4e8c0d72323dc774a6b9b66addc" - integrity sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw== +htmx.org@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.4.tgz#74fce66b177eb59c6d251ecf1052a2478743bec9" + integrity sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ== ignore@^5.2.0, ignore@^5.3.1: version "5.3.2" diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 7ca2f575d..443562027 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -3,6 +3,7 @@ {% load helpers %} {% load i18n %} {% load django_htmx %} +{% load plugins %} content #} {% block head %}{% endblock %} + {% plugin_head %} diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index a5afedec6..0d56c4087 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -46,6 +46,10 @@ {% trans "Status" %} {% badge object.get_status_display bg_color=object.get_status_color %} + + {% trans "Sync interval" %} + {{ object.get_sync_interval_display|placeholder }} + {% trans "Last synced" %} {{ object.last_synced|placeholder }} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index d9e170af3..3644337e4 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -30,6 +30,10 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Parent" %} + {{ object.parent|linkify|placeholder }} + {% trans "Color" %} @@ -52,11 +56,25 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
+
+

+ {% trans "Child Device Roles" %} + {% if perms.dcim.add_devicerole %} + + {% endif %} +

+ {% htmx_table 'dcim:devicerole_list' parent_id=object.pk %} +
{% plugin_full_width_page object %}
diff --git a/netbox/templates/dcim/inc/panels/racktype_dimensions.html b/netbox/templates/dcim/inc/panels/racktype_dimensions.html index 03eab981b..0956cddc1 100644 --- a/netbox/templates/dcim/inc/panels/racktype_dimensions.html +++ b/netbox/templates/dcim/inc/panels/racktype_dimensions.html @@ -24,6 +24,16 @@ {% endif %} + + {% trans "Outer Height" %} + + {% if object.outer_height %} + {{ object.outer_height }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + {% trans "Outer Depth" %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 97dcc20f0..02e02a1ed 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -62,6 +62,7 @@ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f702c6608..3f09ec82f 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -1,8 +1,8 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% load tz %} {% load i18n %} +{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -62,8 +62,8 @@ {{ object.device.device_type|linkify }} - {% trans "Module Type" %} - {{ object.module_type|linkify:"full_name" }} + {% trans "Module Bay" %} + {% nested_tree object.module_bay %} {% trans "Status" %} @@ -88,6 +88,25 @@ {% plugin_left_page object %}
+
+

{% trans "Module Type" %}

+ + + + + + + + + + {% for k, v in object.module_type.attributes.items %} + + + + + {% endfor %} +
{% trans "Manufacturer" %}{{ object.module_type.manufacturer|linkify }}
{% trans "Model" %}{{ object.module_type|linkify }}
{{ k }}{{ v|placeholder }}
+
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index b3d53e09b..adc5e2a98 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -23,6 +23,10 @@

{% trans "Module Type" %}

+ + + + @@ -60,6 +64,27 @@ {% plugin_left_page object %}
+
+

{% trans "Attributes" %}

+ {% if not object.profile %} +
+ {% trans "No profile assigned" %} +
+ {% elif object.attributes %} +
{% trans "Profile" %}{{ object.profile|linkify|placeholder }}
{% trans "Manufacturer" %} {{ object.manufacturer|linkify }}
+ {% for k, v in object.attributes.items %} + + + + + {% endfor %} +
{{ k }}{{ v|placeholder }}
+ {% else %} +
+ {% trans "None" %} +
+ {% endif %} +
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/image_attachments.html' %} diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html new file mode 100644 index 000000000..87e576bda --- /dev/null +++ b/netbox/templates/dcim/moduletypeprofile.html @@ -0,0 +1,59 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load i18n %} + +{% block title %}{{ object.name }}{% endblock %} + +{% block content %} +
+
+
+

{% trans "Module Type Profile" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+

+ {% trans "Schema" %} + {% copy_content 'profile_schema' %} +

+
{{ object.schema|json }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+

+ {% trans "Module Types" %} + {% if perms.dcim.add_moduletype %} + + {% endif %} +

+ {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %} +
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 146f6d580..8e44df88e 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -36,6 +36,10 @@ {% trans "Type" %} {{ object.get_type_display }} + + {% trans "Status" %} + {% badge object.get_status_display bg_color=object.get_status_color %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 1e1b75cd5..c6acbb9ea 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 3ae43f210..9beb7c505 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -41,6 +41,7 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/extras/configtemplate.html b/netbox/templates/extras/configtemplate.html index 5218a3b8f..eeb962cfb 100644 --- a/netbox/templates/extras/configtemplate.html +++ b/netbox/templates/extras/configtemplate.html @@ -4,8 +4,8 @@ {% load i18n %} {% block content %} -
-
+
+

{% trans "Config Template" %}

@@ -17,6 +17,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% 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" %}

@@ -23,6 +23,10 @@ + + + + @@ -62,6 +66,9 @@
{% trans "MIME Type" %} {{ object.mime_type|placeholder }}
{% trans "File Name" %}{{ object.file_name|placeholder }}
{% trans "File Extension" %} {{ object.file_extension|placeholder }}
+ {% plugin_left_page object %} +
+

{% trans "Assigned Models" %}

@@ -72,14 +79,10 @@ {% endfor %}
- {% plugin_left_page object %} -
-
-

{% trans "Template" %}

+

{% trans "Environment Parameters" %}

- {% include 'inc/sync_warning.html' %} -
{{ object.template_code }}
+
{{ object.environment_params }}
{% plugin_right_page object %} @@ -87,6 +90,13 @@
+
+

{% trans "Template" %}

+
+ {% include 'inc/sync_warning.html' %} +
{{ object.template_code }}
+
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 4e1379fed..8c5eb13cd 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -28,6 +28,10 @@  
{% 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 }} +
{% include 'inc/panels/tags.html' %} diff --git a/netbox/templates/login.html b/netbox/templates/login.html index e50303911..079d66a67 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -34,48 +34,55 @@ {% endif %}
-
-

{% trans "Log In" %}

+ {% if not login_form_hidden %} +
+

{% trans "Log In" %}

- {# Login form #} -
- {% csrf_token %} + {# Login form #} + + {% csrf_token %} - {# Set post-login URL #} - {% if 'next' in request.GET %} - - {% elif 'next' in request.POST %} - - {% endif %} + {# Set post-login URL #} + {% if 'next' in request.GET %} + + {% elif 'next' in request.POST %} + + {% endif %} -
- - {{ form.username }} - {% for error in form.username.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.username }} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} +
-
- - {{ form.password }} - {% for error in form.password.errors %} -
{{ error }}
- {% endfor %} -
+
+ + {{ form.password }} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} +
- -
-
+ + +
+ {% endif %} {# SSO login #} {% if auth_backends %} -
{% trans "Or" context "Denotes an alternative option" %}
+ {% if not login_form_hidden %} +
{% trans "Or" context "Denotes an alternative option" %}
+ {% endif %}
+ {% if login_form_hidden %} +

{% trans "Log In" %}

+ {% endif %}
{% for backend in auth_backends %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index b2d1a4078..f34a3573f 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -18,8 +18,18 @@

{% trans "Contact" %}

- - + + 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 @@
{% trans "Group" %}{{ object.group|linkify|placeholder }}{% trans "Groups" %} + {% if object.groups.all|length > 0 %} +
    + {% for group in object.groups.all %} +
  1. {{ group|linkify|placeholder }}
  2. + {% endfor %} +
+ {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Name" %}
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 0567f2ab3..ecf95a024 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/vpn/l2vpn.html b/netbox/templates/vpn/l2vpn.html index 7f64d8086..2a826bc80 100644 --- a/netbox/templates/vpn/l2vpn.html +++ b/netbox/templates/vpn/l2vpn.html @@ -22,6 +22,10 @@ {% trans "Type" %} {{ object.get_type_display }} + + {% trans "Status" %} + {% badge object.get_status_display bg_color=object.get_status_color %} + {% trans "Description" %} {{ object.description|placeholder }} diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html index cb08b1b52..913e9da4c 100644 --- a/netbox/templates/wireless/wirelesslangroup.html +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -40,6 +40,7 @@
{% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
diff --git a/netbox/tenancy/api/serializers_/contacts.py b/netbox/tenancy/api/serializers_/contacts.py index 8c24df734..7fc6a15e6 100644 --- a/netbox/tenancy/api/serializers_/contacts.py +++ b/netbox/tenancy/api/serializers_/contacts.py @@ -3,7 +3,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from netbox.api.fields import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import ContactAssignment, Contact, ContactGroup, ContactRole @@ -26,7 +26,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer): model = ContactGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'contact_count', '_depth', + 'created', 'last_updated', 'contact_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'contact_count', '_depth') @@ -43,12 +43,17 @@ class ContactRoleSerializer(NetBoxModelSerializer): class ContactSerializer(NetBoxModelSerializer): - group = ContactGroupSerializer(nested=True, required=False, allow_null=True, default=None) + groups = SerializedPKRelatedField( + queryset=ContactGroup.objects.all(), + serializer=ContactGroupSerializer, + required=False, + many=True + ) class Meta: model = Contact fields = [ - 'id', 'url', 'display_url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'id', 'url', 'display_url', 'display', 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/tenancy/api/serializers_/tenants.py b/netbox/tenancy/api/serializers_/tenants.py index 54e906f1d..189397c70 100644 --- a/netbox/tenancy/api/serializers_/tenants.py +++ b/netbox/tenancy/api/serializers_/tenants.py @@ -19,7 +19,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer): model = TenantGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'tenant_count', '_depth', + 'created', 'last_updated', 'tenant_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'tenant_count', '_depth') diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 70330ddb8..371b8ec24 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -44,7 +44,7 @@ class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index e2de18231..ca0142db6 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -22,7 +22,7 @@ __all__ = ( # Contacts # -class ContactGroupFilterSet(OrganizationalModelFilterSet): +class ContactGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), label=_('Parent contact group (ID)'), @@ -46,6 +46,11 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label=_('Contact group (slug)'), ) + contact_id = django_filters.ModelMultipleChoiceFilter( + field_name='contact', + queryset=Contact.objects.all(), + label=_('Contact (ID)'), + ) class Meta: model = ContactGroup @@ -62,15 +67,15 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): class ContactFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', + field_name='groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='group', - lookup_expr='in', + field_name='groups', to_field_name='slug', + lookup_expr='in', label=_('Contact group (slug)'), ) @@ -105,13 +110,13 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): ) group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', label=_('Contact group (ID)'), ) group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contact__group', + field_name='contact__groups', lookup_expr='in', to_field_name='slug', label=_('Contact group (slug)'), @@ -153,7 +158,7 @@ class ContactModelFilterSet(django_filters.FilterSet): ) contact_group = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), - field_name='contacts__contact__group', + field_name='contacts__contact__groups', lookup_expr='in', label=_('Contact group'), ) @@ -163,7 +168,7 @@ class ContactModelFilterSet(django_filters.FilterSet): # Tenancy # -class TenantGroupFilterSet(OrganizationalModelFilterSet): +class TenantGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), label=_('Parent tenant group (ID)'), diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 5af3f22ac..aeed18e12 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -5,7 +5,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.forms import add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet __all__ = ( @@ -33,9 +33,10 @@ class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = TenantGroup - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class TenantBulkEditForm(NetBoxModelBulkEditForm): @@ -67,12 +68,13 @@ class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = ContactGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): @@ -90,8 +92,13 @@ class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): class ContactBulkEditForm(NetBoxModelBulkEditForm): - group = DynamicModelChoiceField( - label=_('Group'), + add_groups = DynamicModelMultipleChoiceField( + label=_('Add groups'), + queryset=ContactGroup.objects.all(), + required=False + ) + remove_groups = DynamicModelMultipleChoiceField( + label=_('Remove groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -127,9 +134,13 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): model = Contact fieldsets = ( - FieldSet('group', 'title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('title', 'phone', 'email', 'address', 'link', 'description'), + FieldSet('add_groups', 'remove_groups', name=_('Groups')), + ) + + nullable_fields = ( + 'add_groups', 'remove_groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments' ) - nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments') class ContactAssignmentBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index f37317549..8234513ae 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelImportForm from tenancy.models import * -from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField __all__ = ( 'ContactAssignmentImportForm', @@ -31,7 +31,7 @@ class TenantGroupImportForm(NetBoxModelImportForm): class Meta: model = TenantGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class TenantImportForm(NetBoxModelImportForm): @@ -65,7 +65,7 @@ class ContactGroupImportForm(NetBoxModelImportForm): class Meta: model = ContactGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class ContactRoleImportForm(NetBoxModelImportForm): @@ -77,17 +77,16 @@ class ContactRoleImportForm(NetBoxModelImportForm): class ContactImportForm(NetBoxModelImportForm): - group = CSVModelChoiceField( - label=_('Group'), + groups = CSVModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, to_field_name='name', - help_text=_('Assigned group') + help_text=_('Group names separated by commas, encased with double quotes (e.g. "Group 1,Group 2")') ) class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'groups', 'description', 'comments', 'tags') class ContactAssignmentImportForm(NetBoxModelImportForm): diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 960ca45b1..6541d9693 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -75,7 +75,7 @@ class ContactFilterForm(NetBoxModelFilterSetForm): queryset=ContactGroup.objects.all(), required=False, null_option='None', - label=_('Group') + label=_('Groups') ) tag = TagFilterField(model) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index bc18deed6..6ef9d8560 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField from utilities.forms.rendering import FieldSet, ObjectAttribute __all__ = ( @@ -27,6 +27,7 @@ class TenantGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Tenant Group')), @@ -35,7 +36,7 @@ class TenantGroupForm(NetBoxModelForm): class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments' ] @@ -70,6 +71,7 @@ class ContactGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Contact Group')), @@ -77,7 +79,7 @@ class ContactGroupForm(NetBoxModelForm): class Meta: model = ContactGroup - fields = ('parent', 'name', 'slug', 'description', 'tags') + fields = ('parent', 'name', 'slug', 'description', 'tags', 'comments') class ContactRoleForm(NetBoxModelForm): @@ -93,8 +95,8 @@ class ContactRoleForm(NetBoxModelForm): class ContactForm(NetBoxModelForm): - group = DynamicModelChoiceField( - label=_('Group'), + groups = DynamicModelMultipleChoiceField( + label=_('Groups'), queryset=ContactGroup.objects.all(), required=False ) @@ -102,7 +104,7 @@ class ContactForm(NetBoxModelForm): fieldsets = ( FieldSet( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'tags', name=_('Contact') ), ) @@ -110,7 +112,7 @@ class ContactForm(NetBoxModelForm): class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'tags', ) widgets = { 'address': forms.Textarea(attrs={'rows': 3}), @@ -123,7 +125,7 @@ class ContactAssignmentForm(NetBoxModelForm): queryset=ContactGroup.objects.all(), required=False, initial_params={ - 'contacts': '$contact' + 'contact': '$contact' } ) contact = DynamicModelChoiceField( diff --git a/netbox/tenancy/graphql/enums.py b/netbox/tenancy/graphql/enums.py new file mode 100644 index 000000000..90fc30483 --- /dev/null +++ b/netbox/tenancy/graphql/enums.py @@ -0,0 +1,9 @@ +import strawberry + +from tenancy.choices import * + +__all__ = ( + 'ContactPriorityEnum', +) + +ContactPriorityEnum = strawberry.enum(ContactPriorityChoices.as_enum()) diff --git a/netbox/tenancy/graphql/filter_mixins.py b/netbox/tenancy/graphql/filter_mixins.py new file mode 100644 index 000000000..cc4a4297c --- /dev/null +++ b/netbox/tenancy/graphql/filter_mixins.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry import ID + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from netbox.graphql.filter_lookups import TreeNodeFilter + from .filters import ContactFilter, TenantFilter, TenantGroupFilter + +__all__ = ( + 'ContactFilterMixin', + 'TenancyFilterMixin', +) + + +@dataclass +class ContactFilterMixin(BaseFilterMixin): + contacts: Annotated['ContactFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + + +@dataclass +class TenancyFilterMixin(BaseFilterMixin): + tenant: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_id: ID | None = strawberry_django.filter_field() + tenant_group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tenant_group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/filters.py b/netbox/tenancy/graphql/filters.py index e82b1cd07..5abfa0a6c 100644 --- a/netbox/tenancy/graphql/filters.py +++ b/netbox/tenancy/graphql/filters.py @@ -1,7 +1,49 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from tenancy import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import ChangeLogFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import ( + NestedGroupModelFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy import models +from .filter_mixins import ContactFilterMixin + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from circuits.graphql.filters import CircuitFilter + from dcim.graphql.filters import ( + CableFilter, + DeviceFilter, + LocationFilter, + PowerFeedFilter, + RackFilter, + RackReservationFilter, + SiteFilter, + VirtualDeviceContextFilter, + ) + from ipam.graphql.filters import ( + AggregateFilter, + ASNFilter, + ASNRangeFilter, + IPAddressFilter, + IPRangeFilter, + PrefixFilter, + RouteTargetFilter, + VLANFilter, + VRFFilter, + ) + from netbox.graphql.filter_lookups import TreeNodeFilter + from wireless.graphql.filters import WirelessLANFilter, WirelessLinkFilter + from virtualization.graphql.filters import ClusterFilter, VirtualMachineFilter + from vpn.graphql.filters import L2VPNFilter, TunnelFilter + from .enums import * __all__ = ( 'TenantFilter', @@ -14,36 +56,132 @@ __all__ = ( @strawberry_django.filter(models.Tenant, lookups=True) -@autotype_decorator(filtersets.TenantFilterSet) -class TenantFilter(BaseFilterMixin): - pass +class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + circuits: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + sites: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + vlans: Annotated['VLANFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + wireless_lans: Annotated['WirelessLANFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + route_targets: Annotated['RouteTargetFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + locations: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ip_ranges: Annotated['IPRangeFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + rackreservations: Annotated['RackReservationFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + vdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cables: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + virtual_machines: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field() + asn_ranges: Annotated['ASNRangeFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + wireless_links: Annotated['WirelessLinkFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + aggregates: Annotated['AggregateFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + power_feeds: Annotated['PowerFeedFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + devices: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnels: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + clusters: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + l2vpns: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.TenantGroup, lookups=True) -@autotype_decorator(filtersets.TenantGroupFilterSet) -class TenantGroupFilter(BaseFilterMixin): - pass +class TenantGroupFilter(OrganizationalModelFilterMixin): + parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + parent_id: ID | None = strawberry.UNSET + tenants: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + children: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters'), True] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.Contact, lookups=True) -@autotype_decorator(filtersets.ContactFilterSet) -class ContactFilter(BaseFilterMixin): - pass +class ContactFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + title: FilterLookup[str] | None = strawberry_django.filter_field() + phone: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + address: FilterLookup[str] | None = strawberry_django.filter_field() + link: FilterLookup[str] | None = strawberry_django.filter_field() + group: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: Annotated['TreeNodeFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + assignments: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ContactRole, lookups=True) -@autotype_decorator(filtersets.ContactRoleFilterSet) -class ContactRoleFilter(BaseFilterMixin): +class ContactRoleFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.ContactGroup, lookups=True) -@autotype_decorator(filtersets.ContactGroupFilterSet) -class ContactGroupFilter(BaseFilterMixin): - pass +class ContactGroupFilter(NestedGroupModelFilterMixin): + parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ContactAssignment, lookups=True) -@autotype_decorator(filtersets.ContactAssignmentFilterSet) -class ContactAssignmentFilter(BaseFilterMixin): - pass +class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin): + object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + object_id: ID | None = strawberry_django.filter_field() + contact: Annotated['ContactFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + contact_id: ID | None = strawberry_django.filter_field() + role: Annotated['ContactRoleFilter', strawberry.lazy('tenancy.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + role_id: ID | None = strawberry_django.filter_field() + priority: Annotated['ContactPriorityEnum', strawberry.lazy('tenancy.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/tenancy/graphql/mixins.py b/netbox/tenancy/graphql/mixins.py index 9cdba100e..9437a06f2 100644 --- a/netbox/tenancy/graphql/mixins.py +++ b/netbox/tenancy/graphql/mixins.py @@ -9,5 +9,4 @@ __all__ = ( @strawberry.type class ContactAssignmentsMixin: - assignments: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]] # noqa: F821 diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 7baa136b3..1a33b44ab 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, TYPE_CHECKING import strawberry import strawberry_django @@ -6,8 +6,36 @@ import strawberry_django from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType from tenancy import models -from .mixins import ContactAssignmentsMixin from .filters import * +from .mixins import ContactAssignmentsMixin + +if TYPE_CHECKING: + from circuits.graphql.types import CircuitType + from dcim.graphql.types import ( + CableType, + DeviceType, + LocationType, + PowerFeedType, + RackType, + RackReservationType, + SiteType, + VirtualDeviceContextType, + ) + from ipam.graphql.types import ( + AggregateType, + ASNType, + ASNRangeType, + IPAddressType, + IPRangeType, + PrefixType, + RouteTargetType, + VLANType, + VRFType, + ) + from netbox.graphql.types import ContentTypeType + from wireless.graphql.types import WirelessLANType, WirelessLinkType + from virtualization.graphql.types import ClusterType, VirtualMachineType + from vpn.graphql.types import L2VPNType, TunnelType __all__ = ( 'ContactAssignmentType', @@ -23,69 +51,75 @@ __all__ = ( # Tenants # + @strawberry_django.type( models.Tenant, fields='__all__', - filters=TenantFilter + filters=TenantFilter, + pagination=True ) class TenantType(NetBoxObjectType): - group: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None - - asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] - circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] - sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] - vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] - wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] - route_targets: List[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] - locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] - ip_ranges: List[Annotated["IPRangeType", strawberry.lazy('ipam.graphql.types')]] - rackreservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]] - racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] - vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] - prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] - cables: List[Annotated["CableType", strawberry.lazy('dcim.graphql.types')]] - virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] - vrfs: List[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]] - asn_ranges: List[Annotated["ASNRangeType", strawberry.lazy('ipam.graphql.types')]] - wireless_links: List[Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')]] - aggregates: List[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]] - power_feeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] - devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] - tunnels: List[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]] - ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] - clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] - l2vpns: List[Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]] + group: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None + contacts: List[Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')]] + asns: List[Annotated['ASNType', strawberry.lazy('ipam.graphql.types')]] + circuits: List[Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]] + sites: List[Annotated['SiteType', strawberry.lazy('dcim.graphql.types')]] + vlans: List[Annotated['VLANType', strawberry.lazy('ipam.graphql.types')]] + wireless_lans: List[Annotated['WirelessLANType', strawberry.lazy('wireless.graphql.types')]] + route_targets: List[Annotated['RouteTargetType', strawberry.lazy('ipam.graphql.types')]] + locations: List[Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]] + ip_ranges: List[Annotated['IPRangeType', strawberry.lazy('ipam.graphql.types')]] + rackreservations: List[Annotated['RackReservationType', strawberry.lazy('dcim.graphql.types')]] + racks: List[Annotated['RackType', strawberry.lazy('dcim.graphql.types')]] + vdcs: List[Annotated['VirtualDeviceContextType', strawberry.lazy('dcim.graphql.types')]] + prefixes: List[Annotated['PrefixType', strawberry.lazy('ipam.graphql.types')]] + cables: List[Annotated['CableType', strawberry.lazy('dcim.graphql.types')]] + virtual_machines: List[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]] + vrfs: List[Annotated['VRFType', strawberry.lazy('ipam.graphql.types')]] + asn_ranges: List[Annotated['ASNRangeType', strawberry.lazy('ipam.graphql.types')]] + wireless_links: List[Annotated['WirelessLinkType', strawberry.lazy('wireless.graphql.types')]] + aggregates: List[Annotated['AggregateType', strawberry.lazy('ipam.graphql.types')]] + power_feeds: List[Annotated['PowerFeedType', strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]] + tunnels: List[Annotated['TunnelType', strawberry.lazy('vpn.graphql.types')]] + ip_addresses: List[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]] + clusters: List[Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]] + l2vpns: List[Annotated['L2VPNType', strawberry.lazy('vpn.graphql.types')]] @strawberry_django.type( models.TenantGroup, fields='__all__', - filters=TenantGroupFilter + filters=TenantGroupFilter, + pagination=True ) class TenantGroupType(OrganizationalObjectType): - parent: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')] | None tenants: List[TenantType] - children: List[Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')]] + children: List[Annotated['TenantGroupType', strawberry.lazy('tenancy.graphql.types')]] # # Contacts # + @strawberry_django.type( models.Contact, fields='__all__', - filters=ContactFilter + filters=ContactFilter, + pagination=True ) class ContactType(ContactAssignmentsMixin, NetBoxObjectType): - group: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None + groups: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] @strawberry_django.type( models.ContactRole, fields='__all__', - filters=ContactRoleFilter + filters=ContactRoleFilter, + pagination=True ) class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): pass @@ -94,21 +128,23 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType): @strawberry_django.type( models.ContactGroup, fields='__all__', - filters=ContactGroupFilter + filters=ContactGroupFilter, + pagination=True ) class ContactGroupType(OrganizationalObjectType): - parent: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')] | None contacts: List[ContactType] - children: List[Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')]] + children: List[Annotated['ContactGroupType', strawberry.lazy('tenancy.graphql.types')]] @strawberry_django.type( models.ContactAssignment, fields='__all__', - filters=ContactAssignmentFilter + filters=ContactAssignmentFilter, + pagination=True ) class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType): - object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None - contact: Annotated["ContactType", strawberry.lazy('tenancy.graphql.types')] | None - role: Annotated["ContactRoleType", strawberry.lazy('tenancy.graphql.types')] | None + object_type: Annotated['ContentTypeType', strawberry.lazy('netbox.graphql.types')] | None + contact: Annotated['ContactType', strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated['ContactRoleType', strawberry.lazy('tenancy.graphql.types')] | None diff --git a/netbox/tenancy/migrations/0001_squashed_0012.py b/netbox/tenancy/migrations/0001_squashed_0012.py index 8f3f74d9f..d7e0817ee 100644 --- a/netbox/tenancy/migrations/0001_squashed_0012.py +++ b/netbox/tenancy/migrations/0001_squashed_0012.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('extras', '0001_initial'), + ('extras', '0001_squashed'), ] replaces = [ diff --git a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py index 7f681fd91..849386624 100644 --- a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py +++ b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py @@ -6,7 +6,7 @@ import utilities.json class Migration(migrations.Migration): dependencies = [ - ('tenancy', '0011_contactassignment_tags'), + ('tenancy', '0002_squashed_0011'), ] operations = [ diff --git a/netbox/tenancy/migrations/0018_contact_groups.py b/netbox/tenancy/migrations/0018_contact_groups.py new file mode 100644 index 000000000..11030eb49 --- /dev/null +++ b/netbox/tenancy/migrations/0018_contact_groups.py @@ -0,0 +1,68 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_contact_groups(apps, schema_editor): + Contacts = apps.get_model('tenancy', 'Contact') + + qs = Contacts.objects.filter(group__isnull=False) + for contact in qs: + contact.groups.add(contact.group) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0017_natural_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='ContactGroupMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ], + options={ + 'verbose_name': 'contact group membership', + 'verbose_name_plural': 'contact group memberships', + }, + ), + migrations.RemoveConstraint( + model_name='contact', + name='tenancy_contact_unique_group_name', + ), + migrations.AddField( + model_name='contactgroupmembership', + name='contact', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contact' + ), + ), + migrations.AddField( + model_name='contactgroupmembership', + name='group', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='+', to='tenancy.contactgroup' + ), + ), + migrations.AddField( + model_name='contact', + name='groups', + field=models.ManyToManyField( + blank=True, + related_name='contacts', + related_query_name='contact', + through='tenancy.ContactGroupMembership', + to='tenancy.contactgroup', + ), + ), + migrations.AddConstraint( + model_name='contactgroupmembership', + constraint=models.UniqueConstraint(fields=('group', 'contact'), name='unique_group_name'), + ), + migrations.RunPython(code=migrate_contact_groups, reverse_code=migrations.RunPython.noop), + migrations.RemoveField( + model_name='contact', + name='group', + ), + ] diff --git a/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py new file mode 100644 index 000000000..eee2dc351 --- /dev/null +++ b/netbox/tenancy/migrations/0019_contactgroup_comments_tenantgroup_comments.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0018_contact_groups'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='comments', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='tenantgroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 3969c8317..5f39fe0db 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -13,6 +13,7 @@ __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', + 'ContactGroupMembership', 'ContactRole', ) @@ -47,12 +48,12 @@ class Contact(PrimaryModel): """ Contact information for a particular object(s) in NetBox. """ - group = models.ForeignKey( + groups = models.ManyToManyField( to='tenancy.ContactGroup', - on_delete=models.SET_NULL, related_name='contacts', - blank=True, - null=True + through='tenancy.ContactGroupMembership', + related_query_name='contact', + blank=True ) name = models.CharField( verbose_name=_('name'), @@ -84,17 +85,11 @@ class Contact(PrimaryModel): ) clone_fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + 'groups', 'name', 'title', 'phone', 'email', 'address', 'link', ) class Meta: ordering = ['name'] - constraints = ( - models.UniqueConstraint( - fields=('group', 'name'), - name='%(app_label)s_%(class)s_unique_group_name' - ), - ) verbose_name = _('contact') verbose_name_plural = _('contacts') @@ -102,6 +97,18 @@ class Contact(PrimaryModel): return self.name +class ContactGroupMembership(models.Model): + group = models.ForeignKey(ContactGroup, related_name="+", on_delete=models.CASCADE) + contact = models.ForeignKey(Contact, related_name="+", on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['group', 'contact'], name='unique_group_name') + ] + verbose_name = _('contact group membership') + verbose_name_plural = _('contact group memberships') + + class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): object_type = models.ForeignKey( to='contenttypes.ContentType', diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index 56903d6b1..f9441c974 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -25,6 +25,7 @@ class ContactGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) @@ -59,5 +60,6 @@ class TenantGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index c4e35ab1b..e9579cbe5 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -27,11 +27,15 @@ class ContactGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:contactgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = ContactGroup fields = ( - 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'contact_count', 'description') @@ -56,9 +60,9 @@ class ContactTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) - group = tables.Column( - verbose_name=_('Group'), - linkify=True + groups = columns.ManyToManyColumn( + verbose_name=_('Groups'), + linkify_item=('tenancy:contactgroup', {'pk': tables.A('pk')}) ) phone = tables.Column( verbose_name=_('Phone'), @@ -79,10 +83,10 @@ class ContactTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Contact fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', + 'pk', 'name', 'groups', 'title', 'phone', 'email', 'address', 'link', 'description', 'comments', 'assignment_count', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + default_columns = ('pk', 'name', 'groups', 'assignment_count', 'title', 'phone', 'email') class ContactAssignmentTable(NetBoxTable): diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index a10133a64..70f263dbe 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -24,11 +24,15 @@ class TenantGroupTable(NetBoxTable): tags = columns.TagColumn( url_name='tenancy:tenantgroup_list' ) + comments = columns.MarkdownColumn( + verbose_name=_('Comments'), + ) class Meta(NetBoxTable.Meta): model = TenantGroup fields = ( - 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + 'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created', + 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'tenant_count', 'description') diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c32ad3826..55a54c91f 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -21,6 +21,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { 'description': 'New description', + 'comments': 'New Comment', } @classmethod @@ -28,12 +29,17 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): parent_tenant_groups = ( TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'), - TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'), + TenantGroup.objects.create( + name='Parent Tenant Group 2', slug='parent-tenant-group-2', comments='Parent Group 2 comment', + ), ) TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0]) TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0]) - TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0]) + TenantGroup.objects.create( + name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0], + comments='Tenant Group 3 comment' + ) cls.create_data = [ { @@ -50,6 +56,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Tenant Group 6', 'slug': 'tenant-group-6', 'parent': parent_tenant_groups[1].pk, + 'comments': 'Tenant Group 6 comment', }, ] @@ -107,13 +114,18 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): parent_contact_groups = ( - ContactGroup.objects.create(name='Parent Contact Group 1', slug='parent-contact-group-1'), + ContactGroup.objects.create( + name='Parent Contact Group 1', slug='parent-contact-group-1', comments='Parent 1 comment' + ), ContactGroup.objects.create(name='Parent Contact Group 2', slug='parent-contact-group-2'), ) ContactGroup.objects.create(name='Contact Group 1', slug='contact-group-1', parent=parent_contact_groups[0]) ContactGroup.objects.create(name='Contact Group 2', slug='contact-group-2', parent=parent_contact_groups[0]) - ContactGroup.objects.create(name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0]) + ContactGroup.objects.create( + name='Contact Group 3', slug='contact-group-3', parent=parent_contact_groups[0], + comments='Child Group 3 comment', + ) cls.create_data = [ { @@ -125,11 +137,13 @@ class ContactGroupTest(APIViewTestCases.APIViewTestCase): 'name': 'Contact Group 5', 'slug': 'contact-group-5', 'parent': parent_contact_groups[1].pk, + 'comments': '', }, { 'name': 'Contact Group 6', 'slug': 'contact-group-6', 'parent': parent_contact_groups[1].pk, + 'comments': 'Child Group 6 comment', }, ] @@ -170,7 +184,7 @@ class ContactTest(APIViewTestCases.APIViewTestCase): model = Contact brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { - 'group': None, + 'groups': [], 'comments': 'New comments', } @@ -183,20 +197,22 @@ class ContactTest(APIViewTestCases.APIViewTestCase): ) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[0]) + contacts[2].groups.add(contact_groups[0]) cls.create_data = [ { 'name': 'Contact 4', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], }, { 'name': 'Contact 5', - 'group': contact_groups[1].pk, }, { 'name': 'Contact 6', diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index f6890a3d4..fcb354079 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -16,7 +16,7 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Parent group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenant_group in parent_tenant_groups: @@ -27,7 +27,8 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Tenant Group 1A', slug='tenant-group-1a', parent=parent_tenant_groups[0], - description='foobar1' + description='foobar1', + comments='Tenant Group 1A comment', ), TenantGroup( name='Tenant Group 2A', @@ -48,7 +49,10 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): child_tenant_groups = ( TenantGroup(name='Tenant Group 1A1', slug='tenant-group-1a1', parent=tenant_groups[0]), TenantGroup(name='Tenant Group 2A1', slug='tenant-group-2a1', parent=tenant_groups[1]), - TenantGroup(name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2]), + TenantGroup( + name='Tenant Group 3A1', slug='tenant-group-3a1', parent=tenant_groups[2], + comments='Tenant Group 3A1 comment', + ), ) for tenant_group in child_tenant_groups: tenant_group.save() @@ -57,6 +61,13 @@ class TenantGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Tenant Group 1', 'Tenant Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -139,7 +150,7 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_contact_groups = ( ContactGroup(name='Contact Group 1', slug='contact-group-1'), - ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 2', slug='contact-group-2', comments='Parent group 2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) for contact_group in parent_contact_groups: @@ -162,14 +173,18 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): name='Contact Group 3A', slug='contact-group-3a', parent=parent_contact_groups[2], - description='foobar3' + description='foobar3', + comments='Contact Group 3A comment, not a parent', ), ) for contact_group in contact_groups: contact_group.save() child_contact_groups = ( - ContactGroup(name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0]), + ContactGroup( + name='Contact Group 1A1', slug='contact-group-1a1', parent=contact_groups[0], + comments='Contact Group 1A1 comment', + ), ContactGroup(name='Contact Group 2A1', slug='contact-group-2a1', parent=contact_groups[1]), ContactGroup(name='Contact Group 3A1', slug='contact-group-3a1', parent=contact_groups[2]), ) @@ -180,6 +195,13 @@ class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'q': '1A1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_name(self): params = {'name': ['Contact Group 1', 'Contact Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -241,6 +263,7 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Contact.objects.all() filterset = ContactFilterSet + ignore_fields = ('groups',) @classmethod def setUpTestData(cls): @@ -254,11 +277,14 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0], description='foobar1'), - Contact(name='Contact 2', group=contact_groups[1], description='foobar2'), - Contact(name='Contact 3', group=contact_groups[2], description='foobar3'), + Contact(name='Contact 1', description='foobar1'), + Contact(name='Contact 2', description='foobar2'), + Contact(name='Contact 3', description='foobar3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) def test_q(self): params = {'q': 'foobar1'} @@ -311,11 +337,14 @@ class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): ContactRole.objects.bulk_create(contact_roles) contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[1]), - Contact(name='Contact 3', group=contact_groups[2]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) + contacts[2].groups.add(contact_groups[2]) assignments = ( ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index cbdecc0d0..4d1a45a82 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -15,7 +15,7 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant_groups = ( TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), - TenantGroup(name='Tenant Group 2', slug='tenant-group-2'), + TenantGroup(name='Tenant Group 2', slug='tenant-group-2', comments='Tenant Group 2 comment'), TenantGroup(name='Tenant Group 3', slug='tenant-group-3'), ) for tenanantgroup in tenant_groups: @@ -28,24 +28,26 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'tenant-group-x', 'description': 'A new tenant group', 'tags': [t.pk for t in tags], + 'comments': 'Tenant Group X comment', } cls.csv_data = ( - "name,slug,description", - "Tenant Group 4,tenant-group-4,Fourth tenant group", - "Tenant Group 5,tenant-group-5,Fifth tenant group", - "Tenant Group 6,tenant-group-6,Sixth tenant group", + "name,slug,description,comments", + "Tenant Group 4,tenant-group-4,Fourth tenant group,", + "Tenant Group 5,tenant-group-5,Fifth tenant group,", + "Tenant Group 6,tenant-group-6,Sixth tenant group,Sixth tenant group comment", ) cls.csv_update_data = ( - "id,name,description", - f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7", - f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8", - f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9", + "id,name,description,comments", + f"{tenant_groups[0].pk},Tenant Group 7,Fourth tenant group7,Group 7 comment", + f"{tenant_groups[1].pk},Tenant Group 8,Fifth tenant group8,", + f"{tenant_groups[2].pk},Tenant Group 0,Sixth tenant group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New comment', } @@ -106,7 +108,7 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): def setUpTestData(cls): contact_groups = ( - ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 1', slug='contact-group-1', comments='Comment 1'), ContactGroup(name='Contact Group 2', slug='contact-group-2'), ContactGroup(name='Contact Group 3', slug='contact-group-3'), ) @@ -120,24 +122,26 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'slug': 'contact-group-x', 'description': 'A new contact group', 'tags': [t.pk for t in tags], + 'comments': 'Form data comment', } cls.csv_data = ( - "name,slug,description", - "Contact Group 4,contact-group-4,Fourth contact group", - "Contact Group 5,contact-group-5,Fifth contact group", - "Contact Group 6,contact-group-6,Sixth contact group", + "name,slug,description,comments", + "Contact Group 4,contact-group-4,Fourth contact group,", + "Contact Group 5,contact-group-5,Fifth contact group,Fifth comment", + "Contact Group 6,contact-group-6,Sixth contact group,", ) cls.csv_update_data = ( - "id,name,description", - f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7", - f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8", - f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9", + "id,name,description,comments", + f"{contact_groups[0].pk},Contact Group 7,Fourth contact group7,", + f"{contact_groups[1].pk},Contact Group 8,Fifth contact group8,Group 8 comment", + f"{contact_groups[2].pk},Contact Group 0,Sixth contact group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'Bulk update comment', } @@ -196,37 +200,40 @@ class ContactTestCase(ViewTestCases.PrimaryObjectViewTestCase): contactgroup.save() contacts = ( - Contact(name='Contact 1', group=contact_groups[0]), - Contact(name='Contact 2', group=contact_groups[0]), - Contact(name='Contact 3', group=contact_groups[0]), + Contact(name='Contact 1'), + Contact(name='Contact 2'), + Contact(name='Contact 3'), ) Contact.objects.bulk_create(contacts) + contacts[0].groups.add(contact_groups[0]) + contacts[1].groups.add(contact_groups[1]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'name': 'Contact X', - 'group': contact_groups[1].pk, + 'groups': [contact_groups[1].pk], 'comments': 'Some comments', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,name", - "Contact Group 1,Contact 4", - "Contact Group 1,Contact 5", - "Contact Group 1,Contact 6", + "name", + "groups", + "Contact 4", + "Contact 5", + "Contact 6", ) cls.csv_update_data = ( - "id,name,comments", - f"{contacts[0].pk},Contact Group 7,New comments 7", - f"{contacts[1].pk},Contact Group 8,New comments 8", - f"{contacts[2].pk},Contact Group 9,New comments 9", + "id,name,groups,comments", + f'{contacts[0].pk},Contact 7,"Contact Group 1,Contact Group 2",New comments 7', + f'{contacts[1].pk},Contact 8,"Contact Group 1",New comments 8', + f'{contacts[2].pk},Contact 9,"Contact Group 1",New comments 9', ) cls.bulk_edit_data = { - 'group': contact_groups[1].pk, + 'description': "New description", } diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 0988d2e65..dd584d745 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,43 +1,13 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext_lazy as _ from netbox.views import generic from utilities.query import count_related -from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view +from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables from .models import * -class ObjectContactsView(generic.ObjectChildrenView): - child_model = ContactAssignment - table = tables.ContactAssignmentTable - filterset = filtersets.ContactAssignmentFilterSet - filterset_form = forms.ContactAssignmentFilterForm - template_name = 'tenancy/object_contacts.html' - tab = ViewTab( - label=_('Contacts'), - badge=lambda obj: obj.contacts.count(), - permission='tenancy.view_contactassignment', - weight=5000 - ) - - def get_children(self, request, parent): - return ContactAssignment.objects.restrict(request.user, 'view').filter( - object_type=ContentType.objects.get_for_model(parent), - object_id=parent.pk - ).order_by('priority', 'contact', 'role') - - def get_table(self, *args, **kwargs): - table = super().get_table(*args, **kwargs) - - # Hide object columns - table.columns.hide('object_type') - table.columns.hide('object') - - return table - - # # Tenant groups # @@ -168,11 +138,6 @@ class TenantBulkDeleteView(generic.BulkDeleteView): table = tables.TenantTable -@register_model_view(Tenant, 'contacts') -class TenantContactsView(ObjectContactsView): - queryset = Tenant.objects.all() - - # # Contact groups # @@ -182,7 +147,7 @@ class ContactGroupListView(generic.ObjectListView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -226,7 +191,7 @@ class ContactGroupBulkEditView(generic.BulkEditView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -240,7 +205,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, - 'group', + 'groups', 'contact_count', cumulative=True ) @@ -349,6 +314,15 @@ class ContactBulkEditView(generic.BulkEditView): table = tables.ContactTable form = forms.ContactBulkEditForm + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + + # Add/remove groups + if form.cleaned_data.get('add_groups', None): + obj.groups.add(*form.cleaned_data['add_groups']) + if form.cleaned_data.get('remove_groups', None): + obj.groups.remove(*form.cleaned_data['remove_groups']) + @register_model_view(Contact, 'bulk_delete', path='delete', detail=False) class ContactBulkDeleteView(generic.BulkDeleteView): diff --git a/netbox/users/graphql/filters.py b/netbox/users/graphql/filters.py index d30781b1c..8f8a8f946 100644 --- a/netbox/users/graphql/filters.py +++ b/netbox/users/graphql/filters.py @@ -1,7 +1,12 @@ -import strawberry_django +from datetime import datetime +from typing import Annotated -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from users import filtersets, models +import strawberry +import strawberry_django +from strawberry_django import DatetimeFilterLookup, FilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin +from users import models __all__ = ( 'GroupFilter', @@ -10,12 +15,20 @@ __all__ = ( @strawberry_django.filter(models.Group, lookups=True) -@autotype_decorator(filtersets.GroupFilterSet) -class GroupFilter(BaseFilterMixin): - pass +class GroupFilter(BaseObjectTypeFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + description: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.User, lookups=True) -@autotype_decorator(filtersets.UserFilterSet) -class UserFilter(BaseFilterMixin): - pass +class UserFilter(BaseObjectTypeFilterMixin): + username: FilterLookup[str] | None = strawberry_django.filter_field() + first_name: FilterLookup[str] | None = strawberry_django.filter_field() + last_name: FilterLookup[str] | None = strawberry_django.filter_field() + email: FilterLookup[str] | None = strawberry_django.filter_field() + is_superuser: FilterLookup[bool] | None = strawberry_django.filter_field() + is_staff: FilterLookup[bool] | None = strawberry_django.filter_field() + is_active: FilterLookup[bool] | None = strawberry_django.filter_field() + date_joined: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + last_login: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field() + groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field() diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index 526bf6e21..c5b338553 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -15,7 +15,8 @@ __all__ = ( @strawberry_django.type( Group, fields=['id', 'name'], - filters=GroupFilter + filters=GroupFilter, + pagination=True ) class GroupType(BaseObjectType): pass @@ -26,7 +27,8 @@ class GroupType(BaseObjectType): fields=[ 'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', ], - filters=UserFilter + filters=UserFilter, + pagination=True ) class UserType(BaseObjectType): groups: List[GroupType] diff --git a/netbox/users/migrations/0001_squashed_0011.py b/netbox/users/migrations/0001_squashed_0011.py index 263604d34..ffba6f21b 100644 --- a/netbox/users/migrations/0001_squashed_0011.py +++ b/netbox/users/migrations/0001_squashed_0011.py @@ -132,20 +132,6 @@ class Migration(migrations.Migration): ( 'object_types', models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), - _connector='OR', - ) - ), related_name='object_permissions', to='contenttypes.ContentType', ), diff --git a/netbox/users/migrations/0007_objectpermission_update_object_types.py b/netbox/users/migrations/0007_objectpermission_update_object_types.py index 598b00b92..3be93270b 100644 --- a/netbox/users/migrations/0007_objectpermission_update_object_types.py +++ b/netbox/users/migrations/0007_objectpermission_update_object_types.py @@ -13,23 +13,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='objectpermission', name='object_types', - field=models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), - _connector='OR', - ) - ), - related_name='object_permissions', - to='core.objecttype', - ), + field=models.ManyToManyField(related_name='object_permissions', to='core.objecttype'), ), ] diff --git a/netbox/users/migrations/0009_update_group_perms.py b/netbox/users/migrations/0009_update_group_perms.py index 7698fd1e7..63fdeffec 100644 --- a/netbox/users/migrations/0009_update_group_perms.py +++ b/netbox/users/migrations/0009_update_group_perms.py @@ -28,22 +28,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='objectpermission', name='object_types', - field=models.ManyToManyField( - limit_choices_to=models.Q( - models.Q( - models.Q( - ( - 'app_label__in', - ['account', 'admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users'], - ), - _negated=True, - ), - models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token', 'group', 'user'])), - _connector='OR', - ) - ), - related_name='object_permissions', - to='core.objecttype', - ), + field=models.ManyToManyField(related_name='object_permissions', to='core.objecttype'), ), ] diff --git a/netbox/users/models/permissions.py b/netbox/users/models/permissions.py index 8b471f12b..772adcdb7 100644 --- a/netbox/users/models/permissions.py +++ b/netbox/users/models/permissions.py @@ -3,7 +3,6 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from users.constants import OBJECTPERMISSION_OBJECT_TYPES from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -31,7 +30,6 @@ class ObjectPermission(models.Model): ) object_types = models.ManyToManyField( to='core.ObjectType', - limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) actions = ArrayField( diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 25d055942..7b3648afa 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,3 +1,5 @@ +import enum + from django.conf import settings from django.utils.translation import gettext_lazy as _ @@ -65,6 +67,23 @@ class ChoiceSet(metaclass=ChoiceSetMeta): def values(cls): return [c[0] for c in unpack_grouped_choices(cls._choices)] + @classmethod + def as_enum(cls, name=None): + """ + Return the ChoiceSet as an Enum. If no name is provided, "Choices" will be stripped from the class name (if + present) and "Enum" will be appended. For example, "CircuitStatusChoices" will become "CircuitStatusEnum". + """ + name = name or f"{cls.__name__.split('Choices')[0]}Enum" + data = {} + choices = cls.values() + + for attr in dir(cls): + value = getattr(cls, attr) + if attr.isupper() and value in choices: + data[attr] = value + + return enum.Enum(name, data) + def unpack_grouped_choices(choices): """ diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 0429fe571..b8985c6b0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -136,9 +136,11 @@ def get_field_value(form, field_name): """ field = form.fields[field_name] - if form.is_bound and (data := form.data.get(field_name)): - if hasattr(field, 'valid_value') and field.valid_value(data): - return data + if form.is_bound and field_name in form.data: + if (value := form.data[field_name]) is None: + return + if hasattr(field, 'valid_value') and field.valid_value(value): + return value return form.get_initial_for_field(field, field_name) diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index cea8c9029..37b3b2dfb 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -7,6 +7,7 @@ from netbox.config import get_config __all__ = ( 'DataFileLoader', + 'render_jinja2', ) @@ -48,10 +49,11 @@ class DataFileLoader(BaseLoader): # Utility functions # -def render_jinja2(template_code, context): +def render_jinja2(template_code, context, environment_params=None): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - environment = SandboxedEnvironment() + environment_params = environment_params or {} + environment = SandboxedEnvironment(**environment_params) environment.filters.update(get_config().JINJA2_FILTERS) return environment.from_string(source=template_code).render(**context) diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py new file mode 100644 index 000000000..724253a50 --- /dev/null +++ b/netbox/utilities/jsonschema.py @@ -0,0 +1,166 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from django import forms +from django.contrib.postgres.forms import SimpleArrayField +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import SchemaError +from jsonschema.validators import validator_for + +from utilities.string import title +from utilities.validators import MultipleOfValidator + +__all__ = ( + 'JSONSchemaProperty', + 'PropertyTypeEnum', + 'StringFormatEnum', + 'validate_schema', +) + + +class PropertyTypeEnum(Enum): + STRING = 'string' + INTEGER = 'integer' + NUMBER = 'number' + BOOLEAN = 'boolean' + ARRAY = 'array' + OBJECT = 'object' + + +class StringFormatEnum(Enum): + EMAIL = 'email' + URI = 'uri' + IRI = 'iri' + UUID = 'uuid' + DATE = 'date' + TIME = 'time' + DATETIME = 'datetime' + + +FORM_FIELDS = { + PropertyTypeEnum.STRING.value: forms.CharField, + PropertyTypeEnum.INTEGER.value: forms.IntegerField, + PropertyTypeEnum.NUMBER.value: forms.FloatField, + PropertyTypeEnum.BOOLEAN.value: forms.BooleanField, + PropertyTypeEnum.ARRAY.value: SimpleArrayField, + PropertyTypeEnum.OBJECT.value: forms.JSONField, +} + +STRING_FORM_FIELDS = { + StringFormatEnum.EMAIL.value: forms.EmailField, + StringFormatEnum.URI.value: forms.URLField, + StringFormatEnum.IRI.value: forms.URLField, + StringFormatEnum.UUID.value: forms.UUIDField, + StringFormatEnum.DATE.value: forms.DateField, + StringFormatEnum.TIME.value: forms.TimeField, + StringFormatEnum.DATETIME.value: forms.DateTimeField, +} + + +@dataclass +class JSONSchemaProperty: + type: PropertyTypeEnum = PropertyTypeEnum.STRING.value + title: str | None = None + description: str | None = None + default: Any = None + enum: list | None = None + + # Strings + minLength: int | None = None + maxLength: int | None = None + pattern: str | None = None # Regex + format: StringFormatEnum | None = None + + # Numbers + minimum: int | float | None = None + maximum: int | float | None = None + multipleOf: int | float | None = None + + # Arrays + items: dict | None = field(default_factory=dict) + + def to_form_field(self, name, required=False): + """ + Instantiate and return a Django form field suitable for editing the property's value. + """ + field_kwargs = { + 'label': self.title or title(name), + 'help_text': self.description, + 'required': required, + 'initial': self.default, + } + + # Choices + if self.enum: + choices = [(v, v) for v in self.enum] + if not required: + choices = [(None, ''), *choices] + field_kwargs['choices'] = choices + + # Arrays + if self.type == PropertyTypeEnum.ARRAY.value: + items_type = self.items.get('type', PropertyTypeEnum.STRING.value) + field_kwargs['base_field'] = FORM_FIELDS[items_type]() + + # String validation + if self.type == PropertyTypeEnum.STRING.value: + if self.minLength is not None: + field_kwargs['min_length'] = self.minLength + if self.maxLength is not None: + field_kwargs['max_length'] = self.maxLength + if self.pattern is not None: + field_kwargs['validators'] = [ + RegexValidator(regex=self.pattern) + ] + + # Integer/number validation + elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value): + field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'}) + if self.minimum: + field_kwargs['min_value'] = self.minimum + if self.maximum: + field_kwargs['max_value'] = self.maximum + if self.multipleOf: + field_kwargs['validators'] = [ + MultipleOfValidator(multiple=self.multipleOf) + ] + + return self.field_class(**field_kwargs) + + @property + def field_class(self): + """ + Resolve the property's type (and string format, if specified) to the appropriate field class. + """ + if self.enum: + if self.type == PropertyTypeEnum.ARRAY.value: + return forms.MultipleChoiceField + return forms.ChoiceField + if self.type == PropertyTypeEnum.STRING.value and self.format is not None: + try: + return STRING_FORM_FIELDS[self.format] + except KeyError: + raise ValueError(f"Unsupported string format type: {self.format}") + try: + return FORM_FIELDS[self.type] + except KeyError: + raise ValueError(f"Unknown property type: {self.type}") + + +def validate_schema(schema): + """ + Check that a minimum JSON schema definition is defined. + """ + # Provide some basic sanity checking (not provided by jsonschema) + if not schema or type(schema) is not dict: + raise ValidationError(_("Invalid JSON schema definition")) + if not schema.get('properties'): + raise ValidationError(_("JSON schema must define properties")) + try: + ValidatorClass = validator_for(schema) + ValidatorClass.check_schema(schema) + except SchemaError as e: + raise ValidationError(_("Invalid JSON schema definition: {error}").format(error=e)) diff --git a/netbox/utilities/proxy.py b/netbox/utilities/proxy.py new file mode 100644 index 000000000..8c9e3d196 --- /dev/null +++ b/netbox/utilities/proxy.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.utils.module_loading import import_string +from urllib.parse import urlparse + +__all__ = ( + 'DefaultProxyRouter', + 'resolve_proxies', +) + + +class DefaultProxyRouter: + """ + Base class for a proxy router. + """ + @staticmethod + def _get_protocol_from_url(url): + """ + Determine the applicable protocol (e.g. HTTP or HTTPS) from the given URL. + """ + return urlparse(url).scheme + + def route(self, url=None, protocol=None, context=None): + """ + Returns the appropriate proxy given a URL or protocol. Arbitrary context data may also be passed where + available. + + Args: + url: The specific request URL for which the proxy will be used (if known) + protocol: The protocol in use (e.g. http or https) (if known) + context: Additional context to aid in proxy selection. May include e.g. the requesting client. + """ + if url and protocol is None: + protocol = self._get_protocol_from_url(url) + if protocol and protocol in settings.HTTP_PROXIES: + return { + protocol: settings.HTTP_PROXIES[protocol] + } + return settings.HTTP_PROXIES + + +def resolve_proxies(url=None, protocol=None, context=None): + """ + Return a dictionary of candidate proxies (compatible with the requests module), or None. + + Args: + url: The specific request URL for which the proxy will be used (optional) + protocol: The protocol in use (e.g. http or https) (optional) + context: Arbitrary additional context to aid in proxy selection (optional) + """ + context = context or {} + + for item in settings.PROXY_ROUTERS: + router = import_string(item) if type(item) is str else item + if proxies := router().route(url=url, protocol=protocol, context=context): + return proxies diff --git a/netbox/utilities/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py index 16e65d697..40e6b8196 100644 --- a/netbox/utilities/templatetags/plugins.py +++ b/netbox/utilities/templatetags/plugins.py @@ -45,6 +45,14 @@ def _get_registered_content(obj, method, template_context): return mark_safe(html) +@register.simple_tag(takes_context=True) +def plugin_head(context): + """ + Render any content embedded by plugins + """ + return _get_registered_content(None, 'head', context) + + @register.simple_tag(takes_context=True) def plugin_navbar(context): """ diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index e58123f03..0b3d4b198 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -144,8 +144,8 @@ class BaseFilterSetTests: # Check that the filter class is correct filter = filters[filter_name] if filter_class is not None: - self.assertIs( - type(filter), + self.assertIsInstance( + filter, filter_class, f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!" ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 6956396d2..1598d3d52 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -391,7 +391,8 @@ class DynamicFilterLookupExpressionTest(TestCase): 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'), diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index a0592f626..8ec1404d5 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,10 +1,11 @@ from django import forms from django.test import TestCase +from dcim.models import Site from netbox.choices import ImportFormatChoices from utilities.forms.bulk_import import BulkImportForm from utilities.forms.forms import BulkRenameForm -from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern +from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern class ExpandIPAddress(TestCase): @@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["find"], " hello ") self.assertEqual(form.cleaned_data["replace"], " world ") + + +class GetFieldValueTest(TestCase): + + @classmethod + def setUpTestData(cls): + class TestForm(forms.Form): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + cls.form_class = TestForm + + cls.sites = ( + Site(name='Test Site 1', slug='test-site-1'), + Site(name='Test Site 2', slug='test-site-2'), + ) + Site.objects.bulk_create(cls.sites) + + def test_unbound_without_initial(self): + form = self.form_class() + self.assertEqual( + get_field_value(form, 'site'), + None + ) + + def test_unbound_with_initial(self): + form = self.form_class(initial={'site': self.sites[0].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_value_without_initial(self): + form = self.form_class({'site': self.sites[0].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_value_with_initial(self): + form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk}) + self.assertEqual( + get_field_value(form, 'site'), + self.sites[0].pk + ) + + def test_bound_null_without_initial(self): + form = self.form_class({'site': None}) + self.assertEqual( + get_field_value(form, 'site'), + None + ) + + def test_bound_null_with_initial(self): + form = self.form_class({'site': None}, initial={'site': self.sites[1].pk}) + self.assertEqual( + get_field_value(form, 'site'), + None + ) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 0e896e52a..4b7529472 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,3 +1,4 @@ +import decimal import re from django.core.exceptions import ValidationError @@ -10,6 +11,7 @@ __all__ = ( 'ColorValidator', 'EnhancedURLValidator', 'ExclusionValidator', + 'MultipleOfValidator', 'validate_regex', ) @@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator): return a in b +class MultipleOfValidator(BaseValidator): + """ + Checks that a field's value is a numeric multiple of the given value. Both values are + cast as Decimals for comparison. + """ + def __init__(self, multiple): + self.multiple = decimal.Decimal(str(multiple)) + super().__init__(limit_value=None) + + def __call__(self, value): + if decimal.Decimal(str(value)) % self.multiple != 0: + raise ValidationError( + _("{value} must be a multiple of {multiple}.").format(value=value, multiple=self.multiple) + ) + + def validate_regex(value): """ Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index b3334ca87..5a9830918 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -206,22 +206,30 @@ class ViewTab: Args: label: Human-friendly text + visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one + argument: the object instance. If a callable is not specified, the tab's visibility will be determined by + its badge (if any) and the value of `hide_if_empty`. badge: A static value or callable to display alongside the label (optional). If a callable is used, it must accept a single argument representing the object being viewed. weight: Numeric weight to influence ordering among other tabs (default: 1000) permission: The permission required to display the tab (optional). - hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a - badge are always displayed.) + hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is + evaluated only if the tab is permitted to be displayed according to the `visible` parameter.) """ - def __init__(self, label, badge=None, weight=1000, permission=None, hide_if_empty=False): + def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False): self.label = label + self.visible = visible self.badge = badge self.weight = weight self.permission = permission self.hide_if_empty = hide_if_empty def render(self, instance): - """Return the attributes needed to render a tab in HTML.""" + """ + Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None. + """ + if self.visible is not None and not self.visible(instance): + return None badge_value = self._get_badge_value(instance) if self.badge and self.hide_if_empty and not badge_value: return None diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index b031d2bf3..06a38da36 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -171,13 +171,15 @@ class VirtualMachineFilterSet( name = MultiValueCharFilter( lookup_expr='iexact' ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = TreeNodeMultipleChoiceFilter( queryset=DeviceRole.objects.all(), + lookup_expr='in', label=_('Role (ID)'), ) - role = django_filters.ModelMultipleChoiceFilter( - field_name='role__slug', + role = TreeNodeMultipleChoiceFilter( + field_name='role', queryset=DeviceRole.objects.all(), + lookup_expr='in', to_field_name='slug', label=_('Role (slug)'), ) diff --git a/netbox/virtualization/graphql/enums.py b/netbox/virtualization/graphql/enums.py new file mode 100644 index 000000000..5b1c54e0c --- /dev/null +++ b/netbox/virtualization/graphql/enums.py @@ -0,0 +1,11 @@ +import strawberry + +from virtualization.choices import * + +__all__ = ( + 'ClusterStatusEnum', + 'VirtualMachineStatusEnum', +) + +ClusterStatusEnum = strawberry.enum(ClusterStatusChoices.as_enum()) +VirtualMachineStatusEnum = strawberry.enum(VirtualMachineStatusChoices.as_enum()) diff --git a/netbox/virtualization/graphql/filter_mixins.py b/netbox/virtualization/graphql/filter_mixins.py new file mode 100644 index 000000000..e4c334425 --- /dev/null +++ b/netbox/virtualization/graphql/filter_mixins.py @@ -0,0 +1,26 @@ +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 netbox.graphql.filter_mixins import NetBoxModelFilterMixin + +if TYPE_CHECKING: + from .filters import VirtualMachineFilter + +__all__ = ( + 'VMComponentFilterMixin', +) + + +@dataclass +class VMComponentFilterMixin(NetBoxModelFilterMixin): + virtual_manchine: 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() + description: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index 610275d37..ab4753616 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -1,7 +1,33 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from virtualization import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin +from extras.graphql.filter_mixins import ConfigContextFilterMixin +from netbox.graphql.filter_mixins import ( + ImageAttachmentFilterMixin, + OrganizationalModelFilterMixin, + PrimaryModelFilterMixin, +) +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from virtualization import models +from virtualization.graphql.filter_mixins import VMComponentFilterMixin + +if TYPE_CHECKING: + from .enums import * + from netbox.graphql.filter_lookups import FloatLookup, IntegerLookup + from dcim.graphql.filters import DeviceFilter, DeviceRoleFilter, MACAddressFilter, PlatformFilter, SiteFilter + from ipam.graphql.filters import ( + FHRPGroupAssignmentFilter, + IPAddressFilter, + ServiceFilter, + VLANGroupFilter, + VRFFilter, + ) + from vpn.graphql.filters import L2VPNFilter, TunnelTerminationFilter __all__ = ( 'ClusterFilter', @@ -14,36 +40,119 @@ __all__ = ( @strawberry_django.filter(models.Cluster, lookups=True) -@autotype_decorator(filtersets.ClusterFilterSet) -class ClusterFilter(BaseFilterMixin): - pass +class ClusterFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['ClusterTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + type_id: ID | None = strawberry_django.filter_field() + group: Annotated['ClusterGroupFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + status: Annotated['ClusterStatusEnum', strawberry.lazy('virtualization.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.ClusterGroup, lookups=True) -@autotype_decorator(filtersets.ClusterGroupFilterSet) -class ClusterGroupFilter(BaseFilterMixin): - pass +class ClusterGroupFilter(ContactFilterMixin, OrganizationalModelFilterMixin): + vlan_groups: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.ClusterType, lookups=True) -@autotype_decorator(filtersets.ClusterTypeFilterSet) -class ClusterTypeFilter(BaseFilterMixin): +class ClusterTypeFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.VirtualMachine, lookups=True) -@autotype_decorator(filtersets.VirtualMachineFilterSet) -class VirtualMachineFilter(BaseFilterMixin): - pass +class VirtualMachineFilter( + ContactFilterMixin, + ImageAttachmentFilterMixin, + RenderConfigFilterMixin, + ConfigContextFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin, +): + name: 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() + cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + cluster_id: ID | None = strawberry_django.filter_field() + device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() + device_id: ID | None = strawberry_django.filter_field() + platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + platform_id: ID | None = strawberry_django.filter_field() + status: Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')] | 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() + 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() + vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + disk: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + serial: FilterLookup[str] | None = strawberry_django.filter_field() + interface_count: FilterLookup[int] | None = strawberry_django.filter_field() + virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field() + interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + services: Annotated['ServiceFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_disks: Annotated['VirtualDiskFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VMInterface, lookups=True) -@autotype_decorator(filtersets.VMInterfaceFilterSet) -class VMInterfaceFilter(BaseFilterMixin): - pass +class VMInterfaceFilter(VMComponentFilterMixin, InterfaceBaseFilterMixin): + ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.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() + 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() + ) + mac_addresses: Annotated['MACAddressFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.VirtualDisk, lookups=True) -@autotype_decorator(filtersets.VirtualDiskFilterSet) -class VirtualDiskFilter(BaseFilterMixin): - pass +class VirtualDiskFilter(VMComponentFilterMixin): + size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 33d6ce450..cfffa85e2 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/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,6 +10,21 @@ from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from virtualization import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import ( + DeviceRoleType, + DeviceType, + LocationType, + MACAddressType, + PlatformType, + RegionType, + SiteGroupType, + SiteType, + ) + from extras.graphql.types import ConfigTemplateType + from ipam.graphql.types import IPAddressType, ServiceType, VLANTranslationPolicyType, VLANType, VRFType + from tenancy.graphql.types import TenantType + __all__ = ( 'ClusterType', 'ClusterGroupType', @@ -30,8 +45,9 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), - filters=ClusterFilter + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], + filters=ClusterFilter, + pagination=True ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None @@ -53,7 +69,8 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType): @strawberry_django.type( models.ClusterGroup, fields='__all__', - filters=ClusterGroupFilter + filters=ClusterGroupFilter, + pagination=True ) class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType): @@ -63,7 +80,8 @@ class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType): @strawberry_django.type( models.ClusterType, fields='__all__', - filters=ClusterTypeFilter + filters=ClusterTypeFilter, + pagination=True ) class ClusterTypeType(OrganizationalObjectType): @@ -73,7 +91,8 @@ class ClusterTypeType(OrganizationalObjectType): @strawberry_django.type( models.VirtualMachine, fields='__all__', - filters=VirtualMachineFilter + filters=VirtualMachineFilter, + pagination=True ) class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): interface_count: BigInt @@ -97,7 +116,8 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType): @strawberry_django.type( models.VMInterface, fields='__all__', - filters=VMInterfaceFilter + filters=VMInterfaceFilter, + pagination=True ) class VMInterfaceType(IPAddressesMixin, ComponentType): _name: str @@ -119,7 +139,8 @@ class VMInterfaceType(IPAddressesMixin, ComponentType): @strawberry_django.type( models.VirtualDisk, fields='__all__', - filters=VirtualDiskFilter + filters=VirtualDiskFilter, + pagination=True ) class VirtualDiskType(ComponentType): pass diff --git a/netbox/virtualization/migrations/0001_squashed_0022.py b/netbox/virtualization/migrations/0001_squashed_0022.py index c7aa35ec7..caa890b13 100644 --- a/netbox/virtualization/migrations/0001_squashed_0022.py +++ b/netbox/virtualization/migrations/0001_squashed_0022.py @@ -13,10 +13,10 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0002_auto_20160622_1821'), - ('ipam', '0001_initial'), - ('extras', '0001_initial'), - ('tenancy', '0001_initial'), + ('dcim', '0002_squashed'), + ('ipam', '0001_squashed'), + ('extras', '0001_squashed'), + ('tenancy', '0001_squashed_0012'), ] replaces = [ @@ -154,7 +154,6 @@ class Migration(migrations.Migration): 'role', models.ForeignKey( blank=True, - limit_choices_to={'vm_role': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', diff --git a/netbox/virtualization/migrations/0023_squashed_0036.py b/netbox/virtualization/migrations/0023_squashed_0036.py index 0665aaab6..6ff8fcae4 100644 --- a/netbox/virtualization/migrations/0023_squashed_0036.py +++ b/netbox/virtualization/migrations/0023_squashed_0036.py @@ -26,9 +26,9 @@ class Migration(migrations.Migration): dependencies = [ ('dcim', '0003_squashed_0130'), - ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ('extras', '0087_squashed_0098'), ('ipam', '0047_squashed_0053'), - ('virtualization', '0022_vminterface_parent'), + ('virtualization', '0001_squashed_0022'), ] operations = [ diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py index a9d2075c1..a19e4e9ce 100644 --- a/netbox/virtualization/migrations/0037_protect_child_interfaces.py +++ b/netbox/virtualization/migrations/0037_protect_child_interfaces.py @@ -6,7 +6,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0036_virtualmachine_config_template'), + ('virtualization', '0023_squashed_0036'), ] operations = [ diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py index 521db1877..31dd72989 100644 --- a/netbox/virtualization/migrations/0044_cluster_scope.py +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -32,7 +32,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/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index fab30c6f2..1922922e8 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -82,7 +82,6 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co to='dcim.DeviceRole', on_delete=models.PROTECT, related_name='virtual_machines', - limit_choices_to={'vm_role': True}, blank=True, null=True ) diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index eef5d6b52..7fbf0045d 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -294,7 +294,8 @@ class VirtualMachineTestCase(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() devices = ( create_test_device('device1', cluster=clusters[0]), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 3c8d7eadc..27b1a08a5 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -203,7 +203,8 @@ class VirtualMachineTestCase(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'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 7682d0fc8..ae2aaa750 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,7 +1,6 @@ from django.contrib import messages from django.db import transaction from django.db.models import Prefetch, Sum -from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -16,7 +15,6 @@ from ipam.models import IPAddress from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.query_functions import CollateAsChar from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view @@ -148,11 +146,6 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView): table = tables.ClusterGroupTable -@register_model_view(ClusterGroup, 'contacts') -class ClusterGroupContactsView(ObjectContactsView): - queryset = ClusterGroup.objects.all() - - # # Clusters # @@ -344,11 +337,6 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): }) -@register_model_view(Cluster, 'contacts') -class ClusterContactsView(ObjectContactsView): - queryset = Cluster.objects.all() - - # # Virtual machines # @@ -442,10 +430,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView): # If a direct export has been requested, return the rendered template content as a # downloadable file. if request.GET.get('export'): - content = context['rendered_config'] or context['error_message'] - response = HttpResponse(content, content_type='text') - filename = f"{instance.name or 'config'}.txt" - response['Content-Disposition'] = f'attachment; filename="{filename}"' + response = context['config_template'].render_to_response(context=context['context_data']) return response return render(request, self.get_template_name(), { @@ -509,11 +494,6 @@ class VirtualMachineBulkDeleteView(generic.BulkDeleteView): table = tables.VirtualMachineTable -@register_model_view(VirtualMachine, 'contacts') -class VirtualMachineContactsView(ObjectContactsView): - queryset = VirtualMachine.objects.all() - - # # VM interfaces # diff --git a/netbox/vpn/api/serializers_/l2vpn.py b/netbox/vpn/api/serializers_/l2vpn.py index c16cbbe1d..2148a81c8 100644 --- a/netbox/vpn/api/serializers_/l2vpn.py +++ b/netbox/vpn/api/serializers_/l2vpn.py @@ -38,7 +38,7 @@ class L2VPNSerializer(NetBoxModelSerializer): class Meta: model = L2VPN fields = [ - 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', + 'id', 'url', 'display_url', 'display', 'identifier', 'name', 'slug', 'type', 'status', 'import_targets', 'export_targets', 'description', 'comments', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' ] brief_fields = ('id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'description') diff --git a/netbox/vpn/choices.py b/netbox/vpn/choices.py index 9847e1b97..db03e48f8 100644 --- a/netbox/vpn/choices.py +++ b/netbox/vpn/choices.py @@ -228,6 +228,7 @@ class L2VPNTypeChoices(ChoiceSet): TYPE_MPLS_EVPN = 'mpls-evpn' TYPE_PBB_EVPN = 'pbb-evpn' TYPE_EVPN_VPWS = 'evpn-vpws' + TYPE_SPB = 'spb' CHOICES = ( ('VPLS', ( @@ -255,6 +256,9 @@ class L2VPNTypeChoices(ChoiceSet): (TYPE_EPTREE, _('Ethernet Private Tree')), (TYPE_EVPTREE, _('Ethernet Virtual Private Tree')), )), + ('Other', ( + (TYPE_SPB, _('SPB')), + )), ) P2P = ( @@ -263,3 +267,17 @@ class L2VPNTypeChoices(ChoiceSet): TYPE_EPLAN, TYPE_EPTREE ) + + +class L2VPNStatusChoices(ChoiceSet): + key = 'L2VPN.status' + + STATUS_ACTIVE = 'active' + STATUS_PLANNED = 'planned' + STATUS_DECOMMISSIONING = 'decommissioning' + + CHOICES = [ + (STATUS_ACTIVE, _('Active'), 'green'), + (STATUS_PLANNED, _('Planned'), 'cyan'), + (STATUS_DECOMMISSIONING, _('Decommissioning'), 'red'), + ] diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 6403b662f..d7d06f991 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -298,6 +298,9 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): choices=L2VPNTypeChoices, null_value=None ) + status = django_filters.MultipleChoiceFilter( + choices=L2VPNStatusChoices, + ) import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', queryset=RouteTarget.objects.all(), @@ -323,7 +326,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = L2VPN - fields = ('id', 'identifier', 'name', 'slug', 'type', 'description') + fields = ('id', 'identifier', 'name', 'slug', 'status', 'type', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/vpn/forms/bulk_edit.py b/netbox/vpn/forms/bulk_edit.py index a7595a2a7..700dadb70 100644 --- a/netbox/vpn/forms/bulk_edit.py +++ b/netbox/vpn/forms/bulk_edit.py @@ -260,6 +260,10 @@ class IPSecProfileBulkEditForm(NetBoxModelBulkEditForm): class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + status = forms.ChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), @@ -279,7 +283,7 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): model = L2VPN fieldsets = ( - FieldSet('type', 'tenant', 'description'), + FieldSet('status', 'type', 'tenant', 'description'), ) nullable_fields = ('tenant', 'description', 'comments') diff --git a/netbox/vpn/forms/bulk_import.py b/netbox/vpn/forms/bulk_import.py index b8d19bb38..925558e60 100644 --- a/netbox/vpn/forms/bulk_import.py +++ b/netbox/vpn/forms/bulk_import.py @@ -260,6 +260,11 @@ class L2VPNImportForm(NetBoxModelImportForm): required=False, to_field_name='name', ) + status = CSVChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + help_text=_('Operational status') + ) type = CSVChoiceField( label=_('Type'), choices=L2VPNTypeChoices, diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 10dc441e2..5503166f0 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -210,9 +210,14 @@ class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = L2VPN fieldsets = ( FieldSet('q', 'filter_id', 'tag'), - FieldSet('type', 'import_target_id', 'export_target_id', name=_('Attributes')), + FieldSet('type', 'status', 'import_target_id', 'export_target_id', name=_('Attributes')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) + status = forms.MultipleChoiceField( + label=_('Status'), + choices=L2VPNStatusChoices, + required=False + ) type = forms.ChoiceField( label=_('Type'), choices=add_blank_choice(L2VPNTypeChoices), diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index d6d02b4f5..1bf5b580c 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -409,7 +409,7 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - FieldSet('name', 'slug', 'type', 'identifier', 'description', 'tags', name=_('L2VPN')), + FieldSet('name', 'slug', 'type', 'status', 'identifier', 'description', 'tags', name=_('L2VPN')), FieldSet('import_targets', 'export_targets', name=_('Route Targets')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) @@ -417,8 +417,8 @@ class L2VPNForm(TenancyForm, NetBoxModelForm): class Meta: model = L2VPN fields = ( - 'name', 'slug', 'type', 'identifier', 'import_targets', 'export_targets', 'tenant', 'description', - 'comments', 'tags' + 'name', 'slug', 'type', 'status', 'identifier', 'import_targets', 'export_targets', 'tenant', + 'description', 'comments', 'tags' ) diff --git a/netbox/vpn/graphql/enums.py b/netbox/vpn/graphql/enums.py new file mode 100644 index 000000000..053932ade --- /dev/null +++ b/netbox/vpn/graphql/enums.py @@ -0,0 +1,31 @@ +import strawberry + +from vpn.choices import * + +__all__ = ( + 'AuthenticationAlgorithmEnum', + 'AuthenticationMethodEnum', + 'DHGroupEnum', + 'EncryptionAlgorithmEnum', + 'IKEModeEnum', + 'IKEVersionEnum', + 'IPSecModeEnum', + 'L2VPNTypeEnum', + 'TunnelEncapsulationEnum', + 'TunnelStatusEnum', + 'TunnelTerminationRoleEnum', + 'TunnelTerminationTypeEnum', +) + +AuthenticationAlgorithmEnum = strawberry.enum(AuthenticationAlgorithmChoices.as_enum()) +AuthenticationMethodEnum = strawberry.enum(AuthenticationMethodChoices.as_enum()) +DHGroupEnum = strawberry.enum(DHGroupChoices.as_enum()) +EncryptionAlgorithmEnum = strawberry.enum(EncryptionAlgorithmChoices.as_enum()) +IKEModeEnum = strawberry.enum(IKEModeChoices.as_enum()) +IKEVersionEnum = strawberry.enum(IKEVersionChoices.as_enum()) +IPSecModeEnum = strawberry.enum(IPSecModeChoices.as_enum()) +L2VPNTypeEnum = strawberry.enum(L2VPNTypeChoices.as_enum()) +TunnelEncapsulationEnum = strawberry.enum(TunnelEncapsulationChoices.as_enum()) +TunnelStatusEnum = strawberry.enum(TunnelStatusChoices.as_enum()) +TunnelTerminationRoleEnum = strawberry.enum(TunnelTerminationRoleChoices.as_enum()) +TunnelTerminationTypeEnum = strawberry.enum(TunnelTerminationTypeChoices.as_enum()) diff --git a/netbox/vpn/graphql/filters.py b/netbox/vpn/graphql/filters.py index 34594458b..4e12012dd 100644 --- a/netbox/vpn/graphql/filters.py +++ b/netbox/vpn/graphql/filters.py @@ -1,7 +1,21 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from vpn import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin +from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin +from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin +from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin +from vpn import models + +if TYPE_CHECKING: + from core.graphql.filters import ContentTypeFilter + from ipam.graphql.filters import IPAddressFilter, RouteTargetFilter + from netbox.graphql.filter_lookups import IntegerLookup + from .enums import * __all__ = ( 'TunnelGroupFilter', @@ -18,60 +32,143 @@ __all__ = ( @strawberry_django.filter(models.TunnelGroup, lookups=True) -@autotype_decorator(filtersets.TunnelGroupFilterSet) -class TunnelGroupFilter(BaseFilterMixin): +class TunnelGroupFilter(OrganizationalModelFilterMixin): pass @strawberry_django.filter(models.TunnelTermination, lookups=True) -@autotype_decorator(filtersets.TunnelTerminationFilterSet) -class TunnelTerminationFilter(BaseFilterMixin): - pass +class TunnelTerminationFilter( + BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin +): + tunnel: Annotated['TunnelFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + tunnel_id: ID | None = strawberry_django.filter_field() + role: Annotated['TunnelTerminationRoleEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type: Annotated['TunnelTerminationTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + termination_type_id: ID | None = strawberry_django.filter_field() + termination_id: ID | None = strawberry_django.filter_field() + outside_ip: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + outside_ip_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.Tunnel, lookups=True) -@autotype_decorator(filtersets.TunnelFilterSet) -class TunnelFilter(BaseFilterMixin): - pass +class TunnelFilter(TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['TunnelStatusEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['TunnelGroupFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_id: ID | None = strawberry_django.filter_field() + encapsulation: Annotated['TunnelEncapsulationEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + ipsec_profile: Annotated['IPSecProfileFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + tunnel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IKEProposal, lookups=True) -@autotype_decorator(filtersets.IKEProposalFilterSet) -class IKEProposalFilter(BaseFilterMixin): - pass +class IKEProposalFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + authentication_method: Annotated['AuthenticationMethodEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + sa_lifetime: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IKEPolicy, lookups=True) -@autotype_decorator(filtersets.IKEPolicyFilterSet) -class IKEPolicyFilter(BaseFilterMixin): - pass +class IKEPolicyFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + version: Annotated['IKEVersionEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + mode: Annotated['IKEModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + proposals: Annotated['IKEProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + preshared_key: FilterLookup[str] | None = strawberry_django.filter_field() @strawberry_django.filter(models.IPSecProposal, lookups=True) -@autotype_decorator(filtersets.IPSecProposalFilterSet) -class IPSecProposalFilter(BaseFilterMixin): - pass +class IPSecProposalFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + encryption_algorithm: Annotated['EncryptionAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + authentication_algorithm: Annotated['AuthenticationAlgorithmEnum', strawberry.lazy('vpn.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_seconds: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + sa_lifetime_data: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) @strawberry_django.filter(models.IPSecPolicy, lookups=True) -@autotype_decorator(filtersets.IPSecPolicyFilterSet) -class IPSecPolicyFilter(BaseFilterMixin): - pass +class IPSecPolicyFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + proposals: Annotated['IPSecProposalFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + pfs_group: Annotated['DHGroupEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() @strawberry_django.filter(models.IPSecProfile, lookups=True) -@autotype_decorator(filtersets.IPSecProfileFilterSet) -class IPSecProfileFilter(BaseFilterMixin): - pass +class IPSecProfileFilter(PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + mode: Annotated['IPSecModeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + ike_policy: Annotated['IKEPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ike_policy_id: ID | None = strawberry_django.filter_field() + ipsec_policy: Annotated['IPSecPolicyFilter', strawberry.lazy('vpn.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + ipsec_policy_id: ID | None = strawberry_django.filter_field() @strawberry_django.filter(models.L2VPN, lookups=True) -@autotype_decorator(filtersets.L2VPNFilterSet) -class L2VPNFilter(BaseFilterMixin): - pass +class L2VPNFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): + name: FilterLookup[str] | None = strawberry_django.filter_field() + slug: FilterLookup[str] | None = strawberry_django.filter_field() + type: Annotated['L2VPNTypeEnum', strawberry.lazy('vpn.graphql.enums')] | None = strawberry_django.filter_field() + identifier: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | 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() + ) @strawberry_django.filter(models.L2VPNTermination, lookups=True) -@autotype_decorator(filtersets.L2VPNTerminationFilterSet) -class L2VPNTerminationFilter(BaseFilterMixin): - pass +class L2VPNTerminationFilter(NetBoxModelFilterMixin): + l2vpn: Annotated['L2VPNFilter', strawberry.lazy('vpn.graphql.filters')] | None = strawberry_django.filter_field() + l2vpn_id: ID | None = strawberry_django.filter_field() + assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + assigned_object_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/vpn/graphql/types.py b/netbox/vpn/graphql/types.py index 7940bd326..bbf84dd16 100644 --- a/netbox/vpn/graphql/types.py +++ b/netbox/vpn/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 @@ -8,6 +8,13 @@ from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObj from vpn import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import InterfaceType + from ipam.graphql.types import IPAddressType, RouteTargetType, VLANType + from netbox.graphql.types import ContentTypeType + from tenancy.graphql.types import TenantType + from virtualization.graphql.types import VMInterfaceType + __all__ = ( 'IKEPolicyType', 'IKEProposalType', @@ -25,7 +32,8 @@ __all__ = ( @strawberry_django.type( models.TunnelGroup, fields='__all__', - filters=TunnelGroupFilter + filters=TunnelGroupFilter, + pagination=True ) class TunnelGroupType(OrganizationalObjectType): @@ -35,7 +43,8 @@ class TunnelGroupType(OrganizationalObjectType): @strawberry_django.type( models.TunnelTermination, fields='__all__', - filters=TunnelTerminationFilter + filters=TunnelTerminationFilter, + pagination=True ) class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): tunnel: Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')] @@ -46,7 +55,8 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType): @strawberry_django.type( models.Tunnel, fields='__all__', - filters=TunnelFilter + filters=TunnelFilter, + pagination=True ) class TunnelType(NetBoxObjectType): group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None @@ -59,7 +69,8 @@ class TunnelType(NetBoxObjectType): @strawberry_django.type( models.IKEProposal, fields='__all__', - filters=IKEProposalFilter + filters=IKEProposalFilter, + pagination=True ) class IKEProposalType(OrganizationalObjectType): @@ -69,7 +80,8 @@ class IKEProposalType(OrganizationalObjectType): @strawberry_django.type( models.IKEPolicy, fields='__all__', - filters=IKEPolicyFilter + filters=IKEPolicyFilter, + pagination=True ) class IKEPolicyType(OrganizationalObjectType): @@ -80,7 +92,8 @@ class IKEPolicyType(OrganizationalObjectType): @strawberry_django.type( models.IPSecProposal, fields='__all__', - filters=IPSecProposalFilter + filters=IPSecProposalFilter, + pagination=True ) class IPSecProposalType(OrganizationalObjectType): @@ -90,7 +103,8 @@ class IPSecProposalType(OrganizationalObjectType): @strawberry_django.type( models.IPSecPolicy, fields='__all__', - filters=IPSecPolicyFilter + filters=IPSecPolicyFilter, + pagination=True ) class IPSecPolicyType(OrganizationalObjectType): @@ -101,7 +115,8 @@ class IPSecPolicyType(OrganizationalObjectType): @strawberry_django.type( models.IPSecProfile, fields='__all__', - filters=IPSecProfileFilter + filters=IPSecProfileFilter, + pagination=True ) class IPSecProfileType(OrganizationalObjectType): ike_policy: Annotated["IKEPolicyType", strawberry.lazy('vpn.graphql.types')] @@ -113,7 +128,8 @@ class IPSecProfileType(OrganizationalObjectType): @strawberry_django.type( models.L2VPN, fields='__all__', - filters=L2VPNFilter + filters=L2VPNFilter, + pagination=True ) class L2VPNType(ContactsMixin, NetBoxObjectType): tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None @@ -125,8 +141,9 @@ class L2VPNType(ContactsMixin, NetBoxObjectType): @strawberry_django.type( models.L2VPNTermination, - exclude=('assigned_object_type', 'assigned_object_id'), - filters=L2VPNTerminationFilter + exclude=['assigned_object_type', 'assigned_object_id'], + filters=L2VPNTerminationFilter, + pagination=True ) class L2VPNTerminationType(NetBoxObjectType): l2vpn: Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')] diff --git a/netbox/vpn/migrations/0001_initial.py b/netbox/vpn/migrations/0001_initial.py index b44ae3e52..eed3c6329 100644 --- a/netbox/vpn/migrations/0001_initial.py +++ b/netbox/vpn/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), ('extras', '0099_cachedvalue_ordering'), - ('ipam', '0067_ipaddress_index_host'), + ('ipam', '0054_squashed_0067'), ('tenancy', '0012_contactassignment_custom_fields'), ] diff --git a/netbox/vpn/migrations/0002_move_l2vpn.py b/netbox/vpn/migrations/0002_move_l2vpn.py index 5f1480dce..41ccb8a8d 100644 --- a/netbox/vpn/migrations/0002_move_l2vpn.py +++ b/netbox/vpn/migrations/0002_move_l2vpn.py @@ -72,14 +72,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/vpn/migrations/0008_add_l2vpn_status.py b/netbox/vpn/migrations/0008_add_l2vpn_status.py new file mode 100644 index 000000000..8b0267e45 --- /dev/null +++ b/netbox/vpn/migrations/0008_add_l2vpn_status.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0007_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='l2vpn', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/vpn/migrations/0009_remove_redundant_indexes.py b/netbox/vpn/migrations/0009_remove_redundant_indexes.py new file mode 100644 index 000000000..9f474f9ed --- /dev/null +++ b/netbox/vpn/migrations/0009_remove_redundant_indexes.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2b1 on 2025-04-03 18:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vpn', '0008_add_l2vpn_status'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='l2vpntermination', + name='vpn_l2vpnte_assigne_9c55f8_idx', + ), + migrations.RemoveIndex( + model_name='tunneltermination', + name='vpn_tunnelt_termina_c1f04b_idx', + ), + ] diff --git a/netbox/vpn/models/l2vpn.py b/netbox/vpn/models/l2vpn.py index 3e562531d..bab7510ff 100644 --- a/netbox/vpn/models/l2vpn.py +++ b/netbox/vpn/models/l2vpn.py @@ -7,8 +7,7 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from netbox.models import NetBoxModel, PrimaryModel from netbox.models.features import ContactsMixin -from vpn.choices import L2VPNTypeChoices -from vpn.constants import L2VPN_ASSIGNMENT_MODELS +from vpn.choices import L2VPNStatusChoices, L2VPNTypeChoices __all__ = ( 'L2VPN', @@ -33,6 +32,12 @@ class L2VPN(ContactsMixin, PrimaryModel): max_length=50, choices=L2VPNTypeChoices ) + status = models.CharField( + verbose_name=_('status'), + max_length=50, + choices=L2VPNStatusChoices, + default=L2VPNStatusChoices.STATUS_ACTIVE, + ) identifier = models.BigIntegerField( verbose_name=_('identifier'), null=True, @@ -56,7 +61,7 @@ class L2VPN(ContactsMixin, PrimaryModel): null=True ) - clone_fields = ('type',) + clone_fields = ('type', 'status') class Meta: ordering = ('name', 'identifier') @@ -68,6 +73,9 @@ class L2VPN(ContactsMixin, PrimaryModel): return f'{self.name} ({self.identifier})' return f'{self.name}' + def get_status_color(self): + return L2VPNStatusChoices.colors.get(self.status) + @cached_property def can_add_termination(self): if self.type in L2VPNTypeChoices.P2P and self.terminations.count() >= 2: @@ -84,7 +92,6 @@ class L2VPNTermination(NetBoxModel): ) assigned_object_type = models.ForeignKey( to='contenttypes.ContentType', - limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' ) @@ -101,9 +108,6 @@ class L2VPNTermination(NetBoxModel): class Meta: ordering = ('l2vpn',) - indexes = ( - models.Index(fields=('assigned_object_type', 'assigned_object_id')), - ) constraints = ( models.UniqueConstraint( fields=('assigned_object_type', 'assigned_object_id'), diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index 714024a81..b94892126 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -138,9 +138,6 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo class Meta: ordering = ('tunnel', 'role', 'pk') - indexes = ( - models.Index(fields=('termination_type', 'termination_id')), - ) constraints = ( models.UniqueConstraint( fields=('termination_type', 'termination_id'), diff --git a/netbox/vpn/search.py b/netbox/vpn/search.py index c1914dc22..07ab9a5ca 100644 --- a/netbox/vpn/search.py +++ b/netbox/vpn/search.py @@ -79,4 +79,4 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) - display_attrs = ('type', 'identifier', 'tenant', 'description') + display_attrs = ('type', 'status', 'identifier', 'tenant', 'description') diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 9a614ab98..95586461e 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -23,6 +23,9 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + status = columns.ChoiceFieldColumn( + verbose_name=_('Status') + ) import_targets = columns.TemplateColumn( verbose_name=_('Import Targets'), template_code=L2VPN_TARGETS, @@ -43,10 +46,10 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = L2VPN fields = ( - 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'name', 'slug', 'status', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', + 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'identifier', 'type', 'description') + default_columns = ('pk', 'name', 'status', 'identifier', 'type', 'description') class L2VPNTerminationTable(NetBoxTable): diff --git a/netbox/vpn/tests/test_api.py b/netbox/vpn/tests/test_api.py index f2d43718f..19fdf1136 100644 --- a/netbox/vpn/tests/test_api.py +++ b/netbox/vpn/tests/test_api.py @@ -1,4 +1,5 @@ from django.urls import reverse +from rest_framework import status from dcim.choices import InterfaceTypeChoices from dcim.models import Interface @@ -527,19 +528,22 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): 'name': 'L2VPN 4', 'slug': 'l2vpn-4', 'type': 'vxlan', - 'identifier': 33343344 + 'identifier': 33343344, + 'status': L2VPNStatusChoices.STATUS_ACTIVE, }, { 'name': 'L2VPN 5', 'slug': 'l2vpn-5', 'type': 'vxlan', - 'identifier': 33343345 + 'identifier': 33343345, + 'status': L2VPNStatusChoices.STATUS_PLANNED, }, { 'name': 'L2VPN 6', 'slug': 'l2vpn-6', 'type': 'vpws', - 'identifier': 33343346 + 'identifier': 33343346, + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, }, ] bulk_update_data = { @@ -550,12 +554,53 @@ class L2VPNTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + L2VPN( + name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001, + status=L2VPNStatusChoices.STATUS_ACTIVE, + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002, + status=L2VPNStatusChoices.STATUS_PLANNED, + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', type='vpls', + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + ), # No RD ) L2VPN.objects.bulk_create(l2vpns) + def test_status_filter(self): + url = reverse('vpn-api:l2vpn-list') + + self.add_permissions('vpn.view_l2vpn') + response = self.client.get(url, **self.header) + response_data = response.json() + + # all L2VPNs present with not filter + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 3) + + # 1 L2VPN present with active status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_ACTIVE}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + + # 2 L2VPNs present with active and planned status filter + filter_url = f'{filter_url}&status={L2VPNStatusChoices.STATUS_PLANNED}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 2) + + # 1 L2VPN present with decommissioning status filter + filter_url = f'{url}?status={L2VPNStatusChoices.STATUS_DECOMMISSIONING}' + response = self.client.get(filter_url, **self.header) + response_data = response.json() + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response_data['count'], 1) + class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): model = L2VPNTermination diff --git a/netbox/vpn/tests/test_filtersets.py b/netbox/vpn/tests/test_filtersets.py index d2b893766..ee1f9ca72 100644 --- a/netbox/vpn/tests/test_filtersets.py +++ b/netbox/vpn/tests/test_filtersets.py @@ -769,6 +769,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, + status=L2VPNStatusChoices.STATUS_ACTIVE, identifier=65001, description='foobar1' ), @@ -776,6 +777,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VPWS, + status=L2VPNStatusChoices.STATUS_PLANNED, identifier=65002, description='foobar2' ), @@ -783,6 +785,7 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VPLS, + status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, description='foobar3' ), ) @@ -814,6 +817,15 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + self.assertEqual(self.filterset({}, self.queryset).qs.count(), 3) + + params = {'status': [L2VPNStatusChoices.STATUS_ACTIVE, L2VPNStatusChoices.STATUS_PLANNED]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + params = {'status': [L2VPNStatusChoices.STATUS_DECOMMISSIONING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/vpn/tests/test_views.py b/netbox/vpn/tests/test_views.py index 05ac527fe..6d2239169 100644 --- a/netbox/vpn/tests/test_views.py +++ b/netbox/vpn/tests/test_views.py @@ -574,16 +574,25 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): RouteTarget.objects.bulk_create(rts) l2vpns = ( - L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), - L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), - L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') + L2VPN( + name='L2VPN 1', slug='l2vpn-1', status=L2VPNStatusChoices.STATUS_ACTIVE, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001' + ), + L2VPN( + name='L2VPN 2', slug='l2vpn-2', status=L2VPNStatusChoices.STATUS_DECOMMISSIONING, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002' + ), + L2VPN( + name='L2VPN 3', slug='l2vpn-3', status=L2VPNStatusChoices.STATUS_PLANNED, + type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003' + ) ) L2VPN.objects.bulk_create(l2vpns) cls.csv_data = ( - 'name,slug,type,identifier', - 'L2VPN 5,l2vpn-5,vxlan,456', - 'L2VPN 6,l2vpn-6,vxlan,444', + 'name,status,slug,type,identifier', + 'L2VPN 5,active,l2vpn-5,vxlan,456', + 'L2VPN 6,planned,l2vpn-6,vxlan,444', ) cls.csv_update_data = ( @@ -594,12 +603,14 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'description': 'New Description', + 'status': L2VPNStatusChoices.STATUS_DECOMMISSIONING, } cls.form_data = { 'name': 'L2VPN 8', 'slug': 'l2vpn-8', 'type': L2VPNTypeChoices.TYPE_VXLAN, + 'status': L2VPNStatusChoices.STATUS_PLANNED, 'identifier': 123, 'description': 'Description', 'import_targets': [rts[0].pk], diff --git a/netbox/vpn/views.py b/netbox/vpn/views.py index 3372e9412..8206f4541 100644 --- a/netbox/vpn/views.py +++ b/netbox/vpn/views.py @@ -1,6 +1,5 @@ from ipam.tables import RouteTargetTable from netbox.views import generic -from tenancy.views import ObjectContactsView from utilities.query import count_related from utilities.views import GetRelatedModelsMixin, register_model_view from . import filtersets, forms, tables @@ -497,11 +496,6 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable -@register_model_view(L2VPN, 'contacts') -class L2VPNContactsView(ObjectContactsView): - queryset = L2VPN.objects.all() - - # # L2VPN terminations # diff --git a/netbox/wireless/api/serializers_/wirelesslans.py b/netbox/wireless/api/serializers_/wirelesslans.py index 68f79daf6..97d57f9f5 100644 --- a/netbox/wireless/api/serializers_/wirelesslans.py +++ b/netbox/wireless/api/serializers_/wirelesslans.py @@ -26,7 +26,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): model = WirelessLANGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'wirelesslan_count', '_depth', + 'created', 'last_updated', 'wirelesslan_count', 'comments', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'wirelesslan_count', '_depth') diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cc5aefbd8..bd96865ad 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NestedGroupModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -18,7 +18,7 @@ __all__ = ( ) -class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): +class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() ) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 5cd3a157a..1a75512e1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -32,12 +32,13 @@ class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + comments = CommentField() model = WirelessLANGroup fieldsets = ( FieldSet('parent', 'description'), ) - nullable_fields = ('parent', 'description') + nullable_fields = ('parent', 'description', 'comments') class WirelessLANBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 1fece7e46..389dcf25d 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -30,7 +30,7 @@ class WirelessLANGroupImportForm(NetBoxModelImportForm): class Meta: model = WirelessLANGroup - fields = ('name', 'slug', 'parent', 'description', 'tags') + fields = ('name', 'slug', 'parent', 'description', 'tags', 'comments') class WirelessLANImportForm(ScopedImportForm, NetBoxModelImportForm): diff --git a/netbox/wireless/forms/model_forms.py b/netbox/wireless/forms/model_forms.py index 9cfcca7ba..56422ab57 100644 --- a/netbox/wireless/forms/model_forms.py +++ b/netbox/wireless/forms/model_forms.py @@ -24,6 +24,7 @@ class WirelessLANGroupForm(NetBoxModelForm): required=False ) slug = SlugField() + comments = CommentField() fieldsets = ( FieldSet('parent', 'name', 'slug', 'description', 'tags', name=_('Wireless LAN Group')), @@ -32,7 +33,7 @@ class WirelessLANGroupForm(NetBoxModelForm): class Meta: model = WirelessLANGroup fields = [ - 'parent', 'name', 'slug', 'description', 'tags', + 'parent', 'name', 'slug', 'description', 'tags', 'comments', ] diff --git a/netbox/wireless/graphql/enums.py b/netbox/wireless/graphql/enums.py new file mode 100644 index 000000000..d3c6ad21a --- /dev/null +++ b/netbox/wireless/graphql/enums.py @@ -0,0 +1,17 @@ +import strawberry + +from wireless.choices import * + +__all__ = ( + 'WirelessAuthCipherEnum', + 'WirelessAuthTypeEnum', + 'WirelessChannelEnum', + 'WirelessLANStatusEnum', + 'WirelessRoleEnum', +) + +WirelessAuthCipherEnum = strawberry.enum(WirelessAuthCipherChoices.as_enum()) +WirelessAuthTypeEnum = strawberry.enum(WirelessAuthTypeChoices.as_enum()) +WirelessChannelEnum = strawberry.enum(WirelessChannelChoices.as_enum()) +WirelessLANStatusEnum = strawberry.enum(WirelessLANStatusChoices.as_enum()) +WirelessRoleEnum = strawberry.enum(WirelessRoleChoices.as_enum()) diff --git a/netbox/wireless/graphql/filter_mixins.py b/netbox/wireless/graphql/filter_mixins.py new file mode 100644 index 000000000..636bc8a52 --- /dev/null +++ b/netbox/wireless/graphql/filter_mixins.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import Annotated, TYPE_CHECKING + +import strawberry +import strawberry_django +from strawberry_django import FilterLookup + +from core.graphql.filter_mixins import BaseFilterMixin + +if TYPE_CHECKING: + from .enums import * + +__all__ = ( + 'WirelessAuthenticationBaseFilterMixin', +) + + +@dataclass +class WirelessAuthenticationBaseFilterMixin(BaseFilterMixin): + auth_type: Annotated['WirelessAuthTypeEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_cipher: Annotated['WirelessAuthCipherEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + auth_psk: FilterLookup[str] | None = strawberry_django.filter_field() diff --git a/netbox/wireless/graphql/filters.py b/netbox/wireless/graphql/filters.py index 47d04bedc..d71af7ae2 100644 --- a/netbox/wireless/graphql/filters.py +++ b/netbox/wireless/graphql/filters.py @@ -1,7 +1,20 @@ -import strawberry_django +from typing import Annotated, TYPE_CHECKING -from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin -from wireless import filtersets, models +import strawberry +import strawberry_django +from strawberry.scalars import ID +from strawberry_django import FilterLookup + +from dcim.graphql.filter_mixins import ScopedFilterMixin +from netbox.graphql.filter_mixins import DistanceFilterMixin, PrimaryModelFilterMixin, NestedGroupModelFilterMixin +from tenancy.graphql.filter_mixins import TenancyFilterMixin +from wireless import models +from .filter_mixins import WirelessAuthenticationBaseFilterMixin + +if TYPE_CHECKING: + from dcim.graphql.filters import InterfaceFilter + from ipam.graphql.filters import VLANFilter + from .enums import * __all__ = ( 'WirelessLANGroupFilter', @@ -11,18 +24,45 @@ __all__ = ( @strawberry_django.filter(models.WirelessLANGroup, lookups=True) -@autotype_decorator(filtersets.WirelessLANGroupFilterSet) -class WirelessLANGroupFilter(BaseFilterMixin): +class WirelessLANGroupFilter(NestedGroupModelFilterMixin): pass @strawberry_django.filter(models.WirelessLAN, lookups=True) -@autotype_decorator(filtersets.WirelessLANFilterSet) -class WirelessLANFilter(BaseFilterMixin): - pass +class WirelessLANFilter( + WirelessAuthenticationBaseFilterMixin, + ScopedFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) + group: Annotated['WirelessLANGroupFilter', strawberry.lazy('wireless.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + group_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() @strawberry_django.filter(models.WirelessLink, lookups=True) -@autotype_decorator(filtersets.WirelessLinkFilterSet) -class WirelessLinkFilter(BaseFilterMixin): - pass +class WirelessLinkFilter( + WirelessAuthenticationBaseFilterMixin, + DistanceFilterMixin, + TenancyFilterMixin, + PrimaryModelFilterMixin +): + interface_a: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_a_id: ID | None = strawberry_django.filter_field() + interface_b: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + interface_b_id: ID | None = strawberry_django.filter_field() + ssid: FilterLookup[str] | None = strawberry_django.filter_field() + status: Annotated['WirelessLANStatusEnum', strawberry.lazy('wireless.graphql.enums')] | None = ( + strawberry_django.filter_field() + ) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index aa44e9b9f..eeca6a82b 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/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 @@ -7,6 +7,11 @@ from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from wireless import models from .filters import * +if TYPE_CHECKING: + from dcim.graphql.types import DeviceType, InterfaceType, LocationType, RegionType, SiteGroupType, SiteType + from ipam.graphql.types import VLANType + from tenancy.graphql.types import TenantType + __all__ = ( 'WirelessLANType', 'WirelessLANGroupType', @@ -17,7 +22,8 @@ __all__ = ( @strawberry_django.type( models.WirelessLANGroup, fields='__all__', - filters=WirelessLANGroupFilter + filters=WirelessLANGroupFilter, + pagination=True ) class WirelessLANGroupType(OrganizationalObjectType): parent: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None @@ -28,8 +34,9 @@ class WirelessLANGroupType(OrganizationalObjectType): @strawberry_django.type( models.WirelessLAN, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), - filters=WirelessLANFilter + exclude=['scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'], + filters=WirelessLANFilter, + pagination=True ) class WirelessLANType(NetBoxObjectType): group: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None @@ -51,7 +58,8 @@ class WirelessLANType(NetBoxObjectType): @strawberry_django.type( models.WirelessLink, fields='__all__', - filters=WirelessLinkFilter + filters=WirelessLinkFilter, + pagination=True ) class WirelessLinkType(NetBoxObjectType): interface_a: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] diff --git a/netbox/wireless/migrations/0001_squashed_0008.py b/netbox/wireless/migrations/0001_squashed_0008.py index 8886580e1..2ffc287f9 100644 --- a/netbox/wireless/migrations/0001_squashed_0008.py +++ b/netbox/wireless/migrations/0001_squashed_0008.py @@ -4,7 +4,6 @@ import taggit.managers from django.db import migrations, models import utilities.json -import wireless.models class Migration(migrations.Migration): @@ -21,7 +20,7 @@ class Migration(migrations.Migration): dependencies = [ ('ipam', '0002_squashed_0046'), - ('tenancy', '0007_contact_link'), + ('tenancy', '0002_squashed_0011'), ('extras', '0002_squashed_0059'), ('dcim', '0003_squashed_0130'), ] @@ -149,7 +148,6 @@ class Migration(migrations.Migration): ( 'interface_a', models.ForeignKey( - limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface', @@ -158,7 +156,6 @@ class Migration(migrations.Migration): ( 'interface_b', models.ForeignKey( - limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface', diff --git a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py index 334d41bdd..bac1819dd 100644 --- a/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py +++ b/netbox/wireless/migrations/0011_wirelesslan__location_wirelesslan__region_and_more.py @@ -66,7 +66,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/wireless/migrations/0014_wirelesslangroup_comments.py b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py new file mode 100644 index 000000000..9fc1a99d6 --- /dev/null +++ b/netbox/wireless/migrations/0014_wirelesslangroup_comments.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0013_natural_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslangroup', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 61ff72bc1..6cdc6fc5b 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -123,26 +123,18 @@ class WirelessLAN(WirelessAuthenticationBase, CachedScopeMixin, PrimaryModel): return WirelessLANStatusChoices.colors.get(self.status) -def get_wireless_interface_types(): - # Wrap choices in a callable to avoid generating dummy migrations - # when the choices are updated. - return {'type__in': WIRELESS_IFACE_TYPES} - - class WirelessLink(WirelessAuthenticationBase, DistanceMixin, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ interface_a = models.ForeignKey( to='dcim.Interface', - limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', verbose_name=_('interface A'), ) interface_b = models.ForeignKey( to='dcim.Interface', - limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, related_name='+', verbose_name=_('interface B'), diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index e1be53c09..3c1565cb7 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -21,6 +21,7 @@ class WirelessLANGroupIndex(SearchIndex): ('name', 100), ('slug', 110), ('description', 500), + ('comments', 5000), ) display_attrs = ('description',) diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index f768eafaf..0fe5e45f6 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -24,10 +24,12 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): { 'name': 'Wireless LAN Group 4', 'slug': 'wireless-lan-group-4', + 'comments': '', }, { 'name': 'Wireless LAN Group 5', 'slug': 'wireless-lan-group-5', + 'comments': 'LAN Group 5 comment', }, { 'name': 'Wireless LAN Group 6', @@ -36,6 +38,7 @@ class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): ] bulk_update_data = { 'description': 'New description', + 'comments': 'New comment', } @classmethod diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 27aab83d8..9e8905d4a 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -21,7 +21,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): parent_groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), - WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + WirelessLANGroup( + name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C', + comments='Parent Group 3 comment', + ), ) for group in parent_groups: group.save() @@ -38,10 +41,15 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): slug='wireless-lan-group-1b', parent=parent_groups[0], description='foobar2', + comments='Child Group 1B comment', ), WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=parent_groups[1]), WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=parent_groups[1]), - WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2]), + WirelessLANGroup( + name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=parent_groups[2], + comments='Wireless LAN Group 3A comment', + + ), WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=parent_groups[2]), ) for group in groups: @@ -62,6 +70,13 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'q': 'foobar1'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_q_comments(self): + params = {'q': 'parent'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + params = {'q': 'comment'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_name(self): params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 51af37364..975f18c0d 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -16,7 +16,9 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), - WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup( + name='Wireless LAN Group 2', slug='wireless-lan-group-2', comments='LAN Group 2 comment', + ), WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), ) for group in groups: @@ -30,24 +32,26 @@ class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'parent': groups[2].pk, 'description': 'A new wireless LAN group', 'tags': [t.pk for t in tags], + 'comments': 'LAN Group X comment', } cls.csv_data = ( - "name,slug,description", - "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", - "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", - "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + "name,slug,description,comments", + "Wireless LAN Group 4,wireless-lan-group-4,Fourth wireless LAN group,", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group,", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group,LAN Group 6 comment", ) cls.csv_update_data = ( - "id,name,description", - f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7", - f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8", - f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9", + "id,name,description,comments", + f"{groups[0].pk},Wireless LAN Group 7,Fourth wireless LAN group7,Group 7 comment", + f"{groups[1].pk},Wireless LAN Group 8,Fifth wireless LAN group8,", + f"{groups[2].pk},Wireless LAN Group 0,Sixth wireless LAN group9,", ) cls.bulk_edit_data = { 'description': 'New description', + 'comments': 'New Comments', } diff --git a/requirements.txt b/requirements.txt index cb62f6e6f..1111af085 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==5.1.5 +Django==5.2.0 django-cors-headers==4.6.0 django-debug-toolbar==5.0.1 django-filter==24.3 @@ -10,6 +10,7 @@ django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.13.0 django-rq==3.0 +django-storages==1.14.4 django-taggit==6.1.0 django-tables2==2.7.5 django-timezone-field==7.1 @@ -19,9 +20,10 @@ drf-spectacular-sidecar==2025.2.1 feedparser==6.0.11 gunicorn==23.0.0 Jinja2==3.1.5 +jsonschema==4.23.0 Markdown==3.7 -mkdocs-material==9.6.2 -mkdocstrings[python-legacy]==0.27.0 +mkdocs-material==9.6.7 +mkdocstrings[python]==0.28.2 netaddr==1.3.0 nh3==0.2.20 Pillow==11.1.0