Merge pull request #13614 from netbox-community/develop

Release v3.6.0
This commit is contained in:
Jeremy Stretch 2023-08-30 14:51:04 -04:00 committed by GitHub
commit dfcfbe240d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
587 changed files with 135308 additions and 6054 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.5.9 placeholder: v3.6.0
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.5.9 placeholder: v3.6.0
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -2,13 +2,9 @@
# https://github.com/mozilla/bleach/blob/main/CHANGES # https://github.com/mozilla/bleach/blob/main/CHANGES
bleach bleach
# Python client for Amazon AWS API
# https://github.com/boto/boto3/blob/develop/CHANGELOG.rst
boto3
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/ # https://docs.djangoproject.com/en/stable/releases/
Django<4.2 Django<5.0
# Django middleware which permits cross-domain API requests # Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@ -74,10 +70,6 @@ drf-spectacular
# https://github.com/tfranzel/drf-spectacular-sidecar # https://github.com/tfranzel/drf-spectacular-sidecar
drf-spectacular-sidecar drf-spectacular-sidecar
# Git client for file sync
# https://github.com/jelmer/dulwich/releases
dulwich
# RSS feed parser # RSS feed parser
# https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst # https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst
feedparser feedparser
@ -121,8 +113,8 @@ netaddr
Pillow Pillow
# PostgreSQL database adapter for Python # PostgreSQL database adapter for Python
# https://www.psycopg.org/docs/news.html # https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg2-binary psycopg[binary,pool]
# YAML rendering library # YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES # https://github.com/yaml/pyyaml/blob/master/CHANGES

View File

@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
### Default Permissions
#### Example Constraint Definitions !!! info "This feature was introduced in NetBox v3.6."
While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported.
### Example Constraint Definitions
| Constraints | Description | | Constraints | Description |
| ----------- | ----------- | | ----------- | ----------- |

View File

@ -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. 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 !!! note
These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend). These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
### Archive the Media Directory ### Archive the Media Directory

View File

@ -4,7 +4,7 @@
Default: Empty Default: Empty
A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
!!! warning !!! warning
Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled.

View File

@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE ## DATABASE
NetBox requires access to a PostgreSQL 11 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: NetBox requires access to a PostgreSQL 12 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:
* `NAME` - Database name * `NAME` - Database name
* `USER` - PostgreSQL username * `USER` - PostgreSQL username

View File

@ -4,7 +4,7 @@
Default: True Default: True
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 immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions. 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.
--- ---
@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = (
--- ---
## DEFAULT_PERMISSIONS
!!! info "This parameter was introduced in NetBox v3.6."
Default:
```python
{
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
}
```
This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose.
For example, to allow all users to create a device role beginning with the word "temp," you could configure the following:
```python
DEFAULT_PERMISSIONS = {
'dcim.add_devicerole': (
{'name__startswith': 'temp'},
)
}
```
!!! warning
Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration.
---
## EXEMPT_VIEW_PERMISSIONS ## EXEMPT_VIEW_PERMISSIONS
Default: Empty list Default: Empty list

View File

@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
### Custom Selection Fields ### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

@ -390,7 +390,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}', name=f'{site.slug}-switch{i}',
site=site, site=site,
status=DeviceStatusChoices.STATUS_PLANNED, status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role role=switch_role
) )
switch.full_clean() switch.full_clean()
switch.save() switch.save()

View File

@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`.
## Stores ## Stores
### `counter_fields`
A dictionary mapping of models to foreign keys with which cached counter fields are associated.
### `data_backends` ### `data_backends`
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md). A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).

View File

@ -0,0 +1,123 @@
# Internationalization
Beginning with NetBox v4.0, NetBox will leverage [Django's automatic translation](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) to support languages other than English. This page details the areas of the project which require special attention to ensure functioning translation support. Briefly, these include:
* The `verbose_name` and `verbose_name_plural` Meta attributes for each model
* The `verbose_name` and (if defined) `help_text` for each model field
* The `label` for each form field
* Headers for `fieldsets` on each form class
* The `verbose_name` for each table column
* All human-readable strings within templates must be wrapped with `{% trans %}` or `{% blocktrans %}`
The rest of this document elaborates on each of the items above.
## General Guidance
* Wrap human-readable strings with Django's `gettext()` or `gettext_lazy()` utility functions to enable automatic translation. Generally, `gettext_lazy()` is preferred (and sometimes required) to defer translation until the string is displayed.
* By convention, the preferred translation function is typically imported as an underscore (`_`) to minimize boilerplate code. Thus, you will often see translation as e.g. `_("Some text")`. It is still an option to import and use alternative translation functions (e.g. `pgettext()` and `ngettext()`) normally as needed.
* Avoid passing markup and other non-natural language where possible. Everything wrapped by a translation function gets exported to a messages file for translation by a human.
* Where the intended meaning of the translated string may not be obvious, use `pgettext()` or `pgettext_lazy()` to include assisting context for the translator. For example:
```python
# Context, string
pgettext("month name", "May")
```
* **Format strings do not support translation.** Avoid "f" strings for messages that must support translation. Instead, use `format()` to accomplish variable replacement:
```python
# Translation will not work
f"There are {count} objects"
# Do this instead
"There are {count} objects".format(count=count)
```
## Models
1. Import `gettext_lazy` as `_`.
2. Ensure both `verbose_name` and `verbose_name_plural` are defined under the model's `Meta` class and wrapped with the `gettext_lazy()` shortcut.
3. Ensure each model field specifies a `verbose_name` wrapped with `gettext_lazy()`.
4. Ensure any `help_text` attributes on model fields are also wrapped with `gettext_lazy()`.
```python
from django.utils.translation import gettext_lazy as _
class Circuit(PrimaryModel):
commit_rate = models.PositiveIntegerField(
...
verbose_name=_('commit rate (Kbps)'),
help_text=_("Committed rate")
)
class Meta:
verbose_name = _('circuit')
verbose_name_plural = _('circuits')
```
## Forms
1. Import `gettext_lazy` as `_`.
2. All form fields must specify a `label` wrapped with `gettext_lazy()`.
3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`.
```python
from django.utils.translation import gettext_lazy as _
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
...
)
fieldsets = (
(_('Circuit'), ('provider', 'type', 'status', 'description')),
)
```
## Tables
1. Import `gettext_lazy` as `_`.
2. All table columns must specify a `verbose_name` wrapped with `gettext_lazy()`.
```python
from django.utils.translation import gettext_lazy as _
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
provider = tables.Column(
verbose_name=_('Provider'),
...
)
```
## Templates
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement.
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
```
{% load i18n %}
{# A short string #}
<h5 class="card-header">{% trans "Circuit List" %}</h5>
{# A longer string with a context variable #}
{% blocktrans with count=object.circuits.count %}
There are {count} circuits. Would you like to continue?
{% endblocktrans %}
```
!!! warning
The `{% blocktrans %}` tag supports only **limited variable replacement**, comparable to the `format()` method on Python strings. It does not permit access to object attributes or the use of other template tags or filters inside it. Ensure that any necessary context is passed as simple variables.
!!! info
The `{% trans %}` and `{% blocktrans %}` support the inclusion of contextual hints for translators using the `context` argument:
```nohighlight
{% trans "May" context "month name" %}
```

View File

@ -1,7 +1,5 @@
# Configuration Rendering # Configuration Rendering
!!! info "This feature was introduced in NetBox v3.5."
One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network. One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
```mermaid ```mermaid
@ -39,6 +37,14 @@ Configuration templates are written in the [Jinja2 templating language](https://
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device. When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
### Context Data
The objet for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
```
There are {{ dcim.Site.objects.count() }} sites.
```
## Rendering Templates ## Rendering Templates
### Device Configurations ### Device Configurations

View File

@ -18,6 +18,12 @@ The `tag` filter can be specified multiple times to match only objects which hav
GET /api/dcim/devices/?tag=monitored&tag=deprecated GET /api/dcim/devices/?tag=monitored&tag=deprecated
``` ```
## Bookmarks
!!! info "This feature was introduced in NetBox v3.6."
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
## Custom Fields ## Custom Fields
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs. While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.

View File

@ -1,7 +1,5 @@
# Synchronized Data # Synchronized Data
!!! info "This feature was introduced in NetBox v3.5."
Several models in NetBox support the automatic synchronization of local data from a designated remote source. For example, [configuration templates](./configuration-rendering.md) defined in NetBox can source their content from text files stored in a remote git repository. This accomplished using the core [data source](../models/core/datasource.md) and [data file](../models/core/datafile.md) models. Several models in NetBox support the automatic synchronization of local data from a designated remote source. For example, [configuration templates](./configuration-rendering.md) defined in NetBox can source their content from text files stored in a remote git repository. This accomplished using the core [data source](../models/core/datasource.md) and [data file](../models/core/datafile.md) models.
To enable remote data synchronization, the NetBox administrator first designates one or more remote data sources. NetBox currently supports the following source types: To enable remote data synchronization, the NetBox administrator first designates one or more remote data sources. NetBox currently supports the following source types:
@ -12,6 +10,10 @@ To enable remote data synchronization, the NetBox administrator first designates
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.) (Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
!!! info
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database. Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database.
The following NetBox models can be associated with replicated data files: The following NetBox models can be associated with replicated data files:

View File

@ -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). 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 11 or later required" !!! warning "PostgreSQL 12 or later required"
NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation ## Installation
@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable postgresql sudo systemctl enable postgresql
``` ```
Before continuing, verify that you have installed PostgreSQL 11 or later: Before continuing, verify that you have installed PostgreSQL 12 or later:
```no-highlight ```no-highlight
psql -V psql -V

View File

@ -211,6 +211,22 @@ By default, NetBox will use the local filesystem to store uploaded files. To use
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
``` ```
### Remote Data Sources
NetBox supports integration with several remote data sources via configurable backends. Each of these requires the installation of one or more additional libraries.
* Amazon S3: [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html)
* Git: [`dulwich`](https://www.dulwich.io/)
For example, to enable the Amazon S3 backend, add `boto3` to your local requirements file:
```no-highlight
sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
```
!!! info
These packages were previously required in NetBox v3.5 but now are optional.
## Run the Upgrade Script ## Run the Upgrade Script
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions: Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:

View File

@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 11 | | PostgreSQL | 12 |
| Redis | 4.0 | | Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference: Below is a simplified overview of the NetBox application stack for reference:

View File

@ -15,12 +15,12 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
## 2. Update Dependencies to Required Versions ## 2. Update Dependencies to Required Versions
NetBox v3.0 and later require the following: NetBox requires the following dependencies:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 11 | | PostgreSQL | 12 |
| Redis | 4.0 | | Redis | 4.0 |
## 3. Install the Latest Release ## 3. Install the Latest Release

View File

@ -570,27 +570,26 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
!!! note By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
!!! warning "Restricting Token Retrieval" !!! info "Restricting Token Retrieval"
The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter. The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
### Restricting Write Operations
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
#### Client IP Restriction #### Client IP Restriction
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
#### Creating Tokens for Other Users #### Creating Tokens for Other Users
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users. It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.
![Adding the grant action to a permission](../media/admin_ui_grant_permission.png)
!!! warning "Exercise Caution" !!! warning "Exercise Caution"
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise. The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
@ -627,7 +626,7 @@ When a token is used to authenticate a request, its `last_updated` time updated
### Initial Token Provisioning ### Initial Token Provisioning
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. Ideally, each user should provision his or her own API token(s) via the web UI. However, you may encounter a scenario where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. (Note that the user must have permission to create API tokens regardless of the interface used.)
To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint:
@ -671,8 +670,6 @@ This header specifies the API version in use. This will always match the version
### `X-Request-ID` ### `X-Request-ID`
!!! info "This feature was introduced in NetBox v3.5."
This header specifies the unique ID assigned to the received API request. It can be very handy for correlating a request with change records. For example, after creating several new objects, you can filter against the object changes API endpoint to retrieve the resulting change records: This header specifies the unique ID assigned to the received API request. It can be very handy for correlating a request with change records. For example, after creating several new objects, you can filter against the object changes API endpoint to retrieve the resulting change records:
``` ```

View File

@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache | | HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI | | WSGI service | gunicorn or uWSGI |
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 11+ | | Database | PostgreSQL 12+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,7 +1,5 @@
# Provider Accounts # Provider Accounts
!!! info "This model was introduced in NetBox v3.5."
This model can be used to represent individual accounts associated with a provider. This model can be used to represent individual accounts associated with a provider.
## Fields ## Fields

View File

@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
!!! tip !!! tip
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
### Latitude & Longitude
GPS coordinates of the device for geolocation.
### Status ### Status
The device's operational status. The device's operational status.
@ -83,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
!!! tip !!! tip
NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
### Out-of-band (OOB) IP Address
Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network.
### Cluster ### Cluster
If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.) If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.)

View File

@ -61,6 +61,10 @@ The canonical distance between the two vertical rails on a face. (This is typica
The height of the rack, measured in units. The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions ### 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 and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.

View File

@ -0,0 +1,15 @@
# Bookmarks
!!! info "This feature was introduced in NetBox v3.6."
A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
## Fields
### User
The user to whom the bookmark belongs.
### Object
The bookmarked object.

View File

@ -79,9 +79,9 @@ Controls how and whether the custom field is displayed within the NetBox user in
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices. The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
### Choices ### Choice Set
For choice and multi-choice custom fields only. A comma-delimited list of the available choices. For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
### Cloneable ### Cloneable

View File

@ -0,0 +1,29 @@
# Custom Field Choice Sets
!!! info "This feature was introduced in NetBox v3.6."
Single- and multi-selection [custom fields](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
A choice set must define a base choice set and/or a set of arbitrary extra choices.
## Fields
### Name
The human-friendly name of the choice set.
### Base Choices
The set of pre-defined choices to include. Available sets are listed below. This is an optional setting.
* IATA airport codes
* ISO 3166 - Two-letter country codes
* UN/LOCODE - Five-character location identifiers
### Extra Choices
A set of custom choices that will be appended to the base choice set (if any).
### Order Alphabetically
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

View File

@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
### Color ### Color
The color to use when displaying the tag in the NetBox UI. The color to use when displaying the tag in the NetBox UI.
### Object Types
!!! info "This feature was introduced in NetBox v3.6."
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.
If no object types are specified, the tag will be assignable to any type of object.

View File

@ -1,7 +1,5 @@
# ASN Ranges # ASN Ranges
!!! info "This model was introduced in NetBox v3.5."
Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md). Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md).
## Fields ## Fields

View File

@ -1,7 +1,5 @@
# Dashboard Widgets # Dashboard Widgets
!!! info "This feature was introduced in NetBox v3.5."
Each NetBox user can customize his or her personal dashboard by adding and removing widgets and by manipulating the size and position of each. Plugins can register their own dashboard widgets to complement those already available natively. Each NetBox user can customize his or her personal dashboard by adding and removing widgets and by manipulating the size and position of each. Plugins can register their own dashboard widgets to complement those already available natively.
## The DashboardWidget Class ## The DashboardWidget Class

View File

@ -165,19 +165,6 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
options: options:
members: false members: false
## Choice Fields
!!! warning "Obsolete Fields"
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
::: utilities.forms.fields.ChoiceField
options:
members: false
::: utilities.forms.fields.MultipleChoiceField
options:
members: false
## Dynamic Object Fields ## Dynamic Object Fields
::: utilities.forms.fields.DynamicModelChoiceField ::: utilities.forms.fields.DynamicModelChoiceField

View File

@ -26,7 +26,9 @@ Every model includes by default a numeric primary key. This value is generated a
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
* Bookmarks
* Change logging * Change logging
* Cloning
* Custom fields * Custom fields
* Custom links * Custom links
* Custom validation * Custom validation
@ -105,6 +107,8 @@ For more information about database migrations, see the [Django documentation](h
!!! warning !!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
::: netbox.models.features.BookmarksMixin
::: netbox.models.features.ChangeLoggingMixin ::: netbox.models.features.ChangeLoggingMixin
::: netbox.models.features.CloningMixin ::: netbox.models.features.CloningMixin

View File

@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.6](./version-3.6.md) (August 2023)
* Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
* Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
* User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
* Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
* Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
* Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
#### [Version 3.5](./version-3.5.md) (April 2023) #### [Version 3.5](./version-3.5.md) (April 2023)
* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416)) * Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))

View File

@ -0,0 +1,139 @@
# NetBox v3.6
## v3.6.0 (2023-08-30)
### Breaking Changes
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
* The `device` and `device_id` filter for interfaces will no longer include interfaces from virtual chassis peers. Two new filters, `virtual_chassis_member` and `virtual_chassis_member_id`, have been introduced to match all interfaces belonging to the specified device's virtual chassis (if any).
* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
* Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
### New Features
#### Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
* Users
* Groups
* Object permissions
* API tokens
* Configuration revisions
This migration provides a more consistent user experience and unlocks advanced functionality not feasible using Django's built-in views. The admin UI is scheduled for complete removal in NetBox v4.0.
#### Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
Administrators now have the option of configuring default permissions for _all_ users globally, regardless of explicit permission or group assignments granted in the database. This is accomplished by defining the `DEFAULT_PERMISSIONS` configuration parameter. By default, all users are granted permission to manage their own bookmarks and API tokens.
#### User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
Users can now bookmark their favorite objects in NetBox. Bookmarks are accessible under each user's personal bookmarks list, and can also be added as a dashboard widget.
#### Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
Selection and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields. Additionally, each choice within a set can now specify both a raw value and a human-friendly label (see [#13241](https://github.com/netbox-community/netbox/issues/13241)). Pre-existing custom field choices are migrated to choice sets automatically during the upgrade process.
#### Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
Users now have the option to employ one of several pre-defined sets of choices when creating a custom field. These include:
* IATA airport codes
* ISO 3166 country codes
* UN/LOCODE location identifiers
When defining a choice set, one of the above can be employed as the base set, with the option to define extra, custom choices as well.
#### Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
Tags may now be restricted to use with designated object types. Tags that have no specific object types assigned may be used with any object that supports tag assignment.
### Enhancements
* [#6347](https://github.com/netbox-community/netbox/issues/6347) - Cache the number of assigned components for devices and virtual machines
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#11478](https://github.com/netbox-community/netbox/issues/11478) - Introduce `virtual_chassis_member` filter for interfaces & restore default behavior for `device` filter
* [#11519](https://github.com/netbox-community/netbox/issues/11519) - Add a SQL index for IP address host values to optimize queries
* [#11732](https://github.com/netbox-community/netbox/issues/11732) - Prevent inadvertent overwriting of object attributes by competing users
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
* [#12461](https://github.com/netbox-community/netbox/issues/12461) - Add config template rendering for virtual machines
* [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context
* [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments
* [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Bug Fixes
* [#13513](https://github.com/netbox-community/netbox/issues/13513) - Prevent exception when rendering bookmarks widget for anonymous user
* [#13599](https://github.com/netbox-community/netbox/issues/13599) - Fix errant counter increments when editing device/VM components
* [#13605](https://github.com/netbox-community/netbox/issues/13605) - Optimize cached counter migrations to avoid excessive memory consumption
### Other Changes
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.
* [#6391](https://github.com/netbox-community/netbox/issues/6391) - Rename the `device_role` field on Device to `role` for consistency with VirtualMachine
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11
* [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
### REST API Changes
* Introduced the following endpoints:
* `/api/extras/bookmarks/`
* `/api/extras/custom-field-choice-sets/`
* Added the `/api/extras/custom-fields/{id}/choices/` endpoint for select and multi-select custom fields
* dcim.Device
* Renamed `device_role` to `device`. Added a read-only `device_role` field for limited backward compatibility.
* Added the `latitude` and `longitude` fields (for GPS coordinates)
* Added the `oob_ip` field for out-of-band IP address assignment
* dcim.DeviceType
* Added read-only counter fields for assigned component templates:
* `console_port_template_count`
* `console_server_port_template_count`
* `power_port_template_count`
* `power_outlet_template_count`
* `interface_template_count`
* `front_port_template_count`
* `rear_port_template_count`
* `device_bay_template_count`
* `module_bay_template_count`
* `inventory_item_template_count`
* dcim.InterfaceTemplate
* Added the `rf_role` field
* dcim.Platform
* Removed the `napalm_driver` and `napalm_args` fields
* dcim.PowerFeed
* Added the `tenant` field
* dcim.Rack
* Added the `starting_unit` field
* dcim.VirtualChassis
* Added the read-only `member_count` field
* extras.CustomField
* Removed the `choices` array field
* Added the `choice_set` foreign key field (to ChoiceSet)
* extras.Report
* Reports are now returned within a `results` list
* extras.Script
* Scripts are now returned within a `results` list
* extras.Tag
* Added the `object_types` field for optional restriction to specific object types
* extras.Webhook
* Added `custom_fields` and `tags` support
* tenancy.ContactAssignment
* Added `tags` support
* virtualization.VirtualMachine
* Added the `oob_ip` field for out-of-band IP address assignment

View File

@ -206,10 +206,12 @@ nav:
- VirtualChassis: 'models/dcim/virtualchassis.md' - VirtualChassis: 'models/dcim/virtualchassis.md'
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md' - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
- Extras: - Extras:
- Bookmark: 'models/extras/bookmark.md'
- Branch: 'models/extras/branch.md' - Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md' - ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md' - ConfigTemplate: 'models/extras/configtemplate.md'
- CustomField: 'models/extras/customfield.md' - CustomField: 'models/extras/customfield.md'
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
- CustomLink: 'models/extras/customlink.md' - CustomLink: 'models/extras/customlink.md'
- ExportTemplate: 'models/extras/exporttemplate.md' - ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md' - ImageAttachment: 'models/extras/imageattachment.md'
@ -269,10 +271,12 @@ nav:
- Application Registry: 'development/application-registry.md' - Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md' - User Preferences: 'development/user-preferences.md'
- Web UI: 'development/web-ui.md' - Web UI: 'development/web-ui.md'
- Internationalization: 'development/internationalization.md'
- Release Checklist: 'development/release-checklist.md' - Release Checklist: 'development/release-checklist.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md' - git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes: - Release Notes:
- Summary: 'release-notes/index.md' - Summary: 'release-notes/index.md'
- Version 3.6: 'release-notes/version-3.6.md'
- Version 3.5: 'release-notes/version-3.5.md' - Version 3.5: 'release-notes/version-3.5.md'
- Version 3.4: 'release-notes/version-3.4.md' - Version 3.4: 'release-notes/version-3.4.md'
- Version 3.3: 'release-notes/version-3.3.md' - Version 3.3: 'release-notes/version-3.3.md'

View File

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.10 on 2023-07-30 17:49
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0004_netboxgroup_netboxuser'),
]
operations = [
migrations.CreateModel(
name='UserToken',
fields=[
],
options={
'verbose_name': 'token',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('users.token',),
),
]

View File

15
netbox/account/models.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import reverse
from users.models import Token
class UserToken(Token):
"""
Proxy model for users to manage their own API tokens.
"""
class Meta:
proxy = True
verbose_name = 'token'
def get_absolute_url(self):
return reverse('account:usertoken', args=[self.pk])

55
netbox/account/tables.py Normal file
View File

@ -0,0 +1,55 @@
from django.utils.translation import gettext as _
from account.models import UserToken
from netbox.tables import NetBoxTable, columns
__all__ = (
'UserTokenTable',
)
TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
{% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""
class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
"""
key = columns.TemplateColumn(
verbose_name=_('Key'),
template_code=TOKEN,
)
write_enabled = columns.BooleanColumn(
verbose_name=_('Write Enabled')
)
created = columns.DateColumn(
verbose_name=_('Created'),
)
expires = columns.DateColumn(
verbose_name=_('Expires'),
)
last_used = columns.DateTimeColumn(
verbose_name=_('Last Used'),
)
allowed_ips = columns.TemplateColumn(
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = UserToken
fields = (
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)

18
netbox/account/urls.py Normal file
View File

@ -0,0 +1,18 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'account'
urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
]

298
netbox/account/views.py Normal file
View File

@ -0,0 +1,298 @@
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
from utilities.views import register_model_view
#
# Login/logout
#
class LoginView(View):
"""
Perform user authentication via the web UI.
"""
template_name = 'login.html'
@method_decorator(sensitive_post_parameters('password'))
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def gen_auth_data(self, name, url, params):
display_name, icon_name = get_auth_backend_display(name)
return {
'display_name': display_name,
'icon_name': icon_name,
'url': f'{url}?{urlencode(params)}',
}
def get_auth_backends(self, request):
auth_backends = []
saml_idps = get_saml_idps()
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
url = reverse('social:begin', args=[name])
params = {}
if next := request.GET.get('next'):
params['next'] = next
if name.lower() == 'saml' and saml_idps:
for idp in saml_idps:
params['idp'] = idp
data = self.gen_auth_data(name, url, params)
data['display_name'] = f'{data["display_name"]} ({idp})'
auth_backends.append(data)
else:
auth_backends.append(self.gen_auth_data(name, url, params))
return auth_backends
def get(self, request):
form = forms.LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def post(self, request):
logger = logging.getLogger('netbox.auth.login')
form = forms.LoginForm(request, data=request.POST)
if form.is_valid():
logger.debug("Login form validation was successful")
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
if get_config().MAINTENANCE_MODE:
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
# Authenticate user
auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated")
messages.success(request, f"Logged in as {request.user}.")
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def redirect_to_next(self, request, logger):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
else:
if redirect_url:
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
redirect_url = reverse('home')
return HttpResponseRedirect(redirect_url)
class LogoutView(View):
"""
Deauthenticate a web user.
"""
def get(self, request):
logger = logging.getLogger('netbox.auth.logout')
# Log out the user
username = request.user
auth_logout(request)
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
return response
#
# User profiles
#
class ProfileView(LoginRequiredMixin, View):
template_name = 'account/profile.html'
def get(self, request):
# Compile changelog table
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)
return render(request, self.template_name, {
'changelog_table': changelog_table,
'active_tab': 'profile',
})
class UserConfigView(LoginRequiredMixin, View):
template_name = 'account/preferences.html'
def get(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
def post(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid():
form.save()
messages.success(request, "Your preferences have been updated.")
return redirect('account:preferences')
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'account/password.html'
def get(self, request):
# LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('account:profile')
form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, {
'form': form,
'active_tab': 'password',
})
def post(self, request):
form = forms.PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.")
return redirect('account:profile')
return render(request, self.template_name, {
'form': form,
'active_tab': 'change_password',
})
#
# Bookmarks
#
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
table = BookmarkTable
template_name = 'account/bookmarks.html'
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def get_extra_context(self, request):
return {
'active_tab': 'bookmarks',
}
#
# User views for token management
#
class UserTokenListView(LoginRequiredMixin, View):
def get(self, request):
tokens = UserToken.objects.filter(user=request.user)
table = tables.UserTokenTable(tokens)
table.configure(request)
return render(request, 'account/token_list.html', {
'tokens': tokens,
'active_tab': 'api-tokens',
'table': table,
})
@register_model_view(UserToken)
class UserTokenView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
return render(request, 'account/token.html', {
'object': token,
'key': key,
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(generic.ObjectEditView):
queryset = UserToken.objects.all()
form = forms.UserTokenForm
default_return_url = 'account:usertoken_list'
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(UserToken, 'delete')
class UserTokenDeleteView(generic.ObjectDeleteView):
queryset = UserToken.objects.all()
default_return_url = 'account:usertoken_list'

View File

@ -1,3 +1,5 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
@ -16,12 +18,12 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONED = 'decommissioned' STATUS_DECOMMISSIONED = 'decommissioned'
CHOICES = [ CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_PROVISIONING, 'Provisioning', 'blue'), (STATUS_PROVISIONING, _('Provisioning'), 'blue'),
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_OFFLINE, 'Offline', 'red'), (STATUS_OFFLINE, _('Offline'), 'red'),
(STATUS_DEPROVISIONING, 'Deprovisioning', 'yellow'), (STATUS_DEPROVISIONING, _('Deprovisioning'), 'yellow'),
(STATUS_DECOMMISSIONED, 'Decommissioned', 'gray'), (STATUS_DECOMMISSIONED, _('Decommissioned'), 'gray'),
] ]

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import * from circuits.models import *
@ -26,12 +26,11 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField( comments = CommentField()
label=_('Comments')
)
model = Provider model = Provider
fieldsets = ( fieldsets = (
@ -44,16 +43,16 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm): class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
required=False required=False
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField( comments = CommentField()
label=_('Comments')
)
model = ProviderAccount model = ProviderAccount
fieldsets = ( fieldsets = (
@ -66,6 +65,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
required=False required=False
) )
@ -75,12 +75,11 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
label=_('Service ID') label=_('Service ID')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField( comments = CommentField()
label=_('Comments')
)
model = ProviderNetwork model = ProviderNetwork
fieldsets = ( fieldsets = (
@ -93,6 +92,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )
@ -106,14 +106,17 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
class CircuitBulkEditForm(NetBoxModelBulkEditForm): class CircuitBulkEditForm(NetBoxModelBulkEditForm):
type = DynamicModelChoiceField( type = DynamicModelChoiceField(
label=_('Type'),
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
required=False required=False
) )
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
required=False required=False
) )
provider_account = DynamicModelChoiceField( provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -121,19 +124,23 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
} }
) )
status = forms.ChoiceField( status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices), choices=add_blank_choice(CircuitStatusChoices),
required=False, required=False,
initial='' initial=''
) )
tenant = DynamicModelChoiceField( tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False required=False
) )
install_date = forms.DateField( install_date = forms.DateField(
label=_('Install date'),
required=False, required=False,
widget=DatePicker() widget=DatePicker()
) )
termination_date = forms.DateField( termination_date = forms.DateField(
label=_('Termination date'),
required=False, required=False,
widget=DatePicker() widget=DatePicker()
) )
@ -145,18 +152,17 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
) )
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=100, max_length=100,
required=False required=False
) )
comments = CommentField( comments = CommentField()
label=_('Comments')
)
model = Circuit model = Circuit
fieldsets = ( fieldsets = (
('Circuit', ('provider', 'type', 'status', 'description')), (_('Circuit'), ('provider', 'type', 'status', 'description')),
('Service Parameters', ('provider_account', 'install_date', 'termination_date', 'commit_rate')), (_('Service Parameters'), ('provider_account', 'install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant',)), (_('Tenancy'), ('tenant',)),
) )
nullable_fields = ( nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments', 'tenant', 'commit_rate', 'description', 'comments',

View File

@ -3,7 +3,7 @@ from django import forms
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import * from circuits.models import *
from dcim.models import Site from dcim.models import Site
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
@ -31,6 +31,7 @@ class ProviderImportForm(NetBoxModelImportForm):
class ProviderAccountImportForm(NetBoxModelImportForm): class ProviderAccountImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField( provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned provider') help_text=_('Assigned provider')
@ -45,6 +46,7 @@ class ProviderAccountImportForm(NetBoxModelImportForm):
class ProviderNetworkImportForm(NetBoxModelImportForm): class ProviderNetworkImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField( provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned provider') help_text=_('Assigned provider')
@ -67,26 +69,31 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class CircuitImportForm(NetBoxModelImportForm): class CircuitImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField( provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned provider') help_text=_('Assigned provider')
) )
provider_account = CSVModelChoiceField( provider_account = CSVModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned provider account'), help_text=_('Assigned provider account'),
required=False required=False
) )
type = CSVModelChoiceField( type = CSVModelChoiceField(
label=_('Type'),
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Type of circuit') help_text=_('Type of circuit')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -103,11 +110,13 @@ class CircuitImportForm(NetBoxModelImportForm):
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm): class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False
) )
provider_network = CSVModelChoiceField( provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False

View File

@ -23,9 +23,9 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)), (_('ASN'), ('asn',)),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -62,7 +62,7 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
model = ProviderAccount model = ProviderAccount
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('provider_id', 'account')), (_('Attributes'), ('provider_id', 'account')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
@ -70,6 +70,7 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
label=_('Provider') label=_('Provider')
) )
account = forms.CharField( account = forms.CharField(
label=_('Account'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -79,7 +80,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
model = ProviderNetwork model = ProviderNetwork
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('provider_id', 'service_id')), (_('Attributes'), ('provider_id', 'service_id')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
@ -87,6 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
label=_('Provider') label=_('Provider')
) )
service_id = forms.CharField( service_id = forms.CharField(
label=_('Service id'),
max_length=100, max_length=100,
required=False required=False
) )
@ -102,11 +104,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
model = Circuit model = Circuit
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Provider', ('provider_id', 'provider_account_id', 'provider_network_id')), (_('Provider'), ('provider_id', 'provider_account_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), (_('Attributes'), ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
) )
type_id = DynamicModelMultipleChoiceField( type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
@ -135,6 +137,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
label=_('Provider network') label=_('Provider network')
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
required=False required=False
) )
@ -158,10 +161,12 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
label=_('Site') label=_('Site')
) )
install_date = forms.DateField( install_date = forms.DateField(
label=_('Install date'),
required=False, required=False,
widget=DatePicker widget=DatePicker
) )
termination_date = forms.DateField( termination_date = forms.DateField(
label=_('Termination date'),
required=False, required=False,
widget=DatePicker widget=DatePicker
) )

View File

@ -1,4 +1,4 @@
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
from circuits.models import * from circuits.models import *
@ -29,7 +29,7 @@ class ProviderForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Provider', ('name', 'slug', 'asns', 'description', 'tags')), (_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')),
) )
class Meta: class Meta:
@ -41,6 +41,7 @@ class ProviderForm(NetBoxModelForm):
class ProviderAccountForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all() queryset=Provider.objects.all()
) )
comments = CommentField() comments = CommentField()
@ -54,12 +55,13 @@ class ProviderAccountForm(NetBoxModelForm):
class ProviderNetworkForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all() queryset=Provider.objects.all()
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), (_('Provider Network'), ('provider', 'name', 'service_id', 'description', 'tags')),
) )
class Meta: class Meta:
@ -73,7 +75,7 @@ class CircuitTypeForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Circuit Type', ( (_('Circuit Type'), (
'name', 'slug', 'description', 'tags', 'name', 'slug', 'description', 'tags',
)), )),
) )
@ -87,10 +89,12 @@ class CircuitTypeForm(NetBoxModelForm):
class CircuitForm(TenancyForm, NetBoxModelForm): class CircuitForm(TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField( provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
selector=True selector=True
) )
provider_account = DynamicModelChoiceField( provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(), queryset=ProviderAccount.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -103,9 +107,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Circuit', ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')), (_('Circuit'), ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), (_('Service Parameters'), ('install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant_group', 'tenant')), (_('Tenancy'), ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
@ -125,15 +129,18 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
class CircuitTerminationForm(NetBoxModelForm): class CircuitTerminationForm(NetBoxModelForm):
circuit = DynamicModelChoiceField( circuit = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
selector=True selector=True
) )
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
selector=True selector=True
) )
provider_network = DynamicModelChoiceField( provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
required=False, required=False,
selector=True selector=True

View File

@ -1,14 +1,12 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from circuits.choices import * from circuits.choices import *
from dcim.models import CabledObjectModel from dcim.models import CabledObjectModel
from netbox.models import ( from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin, from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
)
__all__ = ( __all__ = (
'Circuit', 'Circuit',
@ -25,8 +23,13 @@ class CircuitType(OrganizationalModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk]) return reverse('circuits:circuittype', args=[self.pk])
class Meta:
ordering = ('name',)
verbose_name = _('circuit type')
verbose_name_plural = _('circuit types')
class Circuit(PrimaryModel):
class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
""" """
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
@ -34,8 +37,8 @@ class Circuit(PrimaryModel):
""" """
cid = models.CharField( cid = models.CharField(
max_length=100, max_length=100,
verbose_name='Circuit ID', verbose_name=_('circuit ID'),
help_text=_("Unique circuit ID") help_text=_('Unique circuit ID')
) )
provider = models.ForeignKey( provider = models.ForeignKey(
to='circuits.Provider', to='circuits.Provider',
@ -55,6 +58,7 @@ class Circuit(PrimaryModel):
related_name='circuits' related_name='circuits'
) )
status = models.CharField( status = models.CharField(
verbose_name=_('status'),
max_length=50, max_length=50,
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
default=CircuitStatusChoices.STATUS_ACTIVE default=CircuitStatusChoices.STATUS_ACTIVE
@ -69,28 +73,20 @@ class Circuit(PrimaryModel):
install_date = models.DateField( install_date = models.DateField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Installed' verbose_name=_('installed')
) )
termination_date = models.DateField( termination_date = models.DateField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Terminates' verbose_name=_('terminates')
) )
commit_rate = models.PositiveIntegerField( commit_rate = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Commit rate (Kbps)', verbose_name=_('commit rate (Kbps)'),
help_text=_("Committed rate") help_text=_("Committed rate")
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
# Cache associated CircuitTerminations # Cache associated CircuitTerminations
termination_a = models.ForeignKey( termination_a = models.ForeignKey(
to='circuits.CircuitTermination', to='circuits.CircuitTermination',
@ -130,6 +126,8 @@ class Circuit(PrimaryModel):
name='%(app_label)s_%(class)s_unique_provideraccount_cid' name='%(app_label)s_%(class)s_unique_provideraccount_cid'
), ),
) )
verbose_name = _('circuit')
verbose_name_plural = _('circuits')
def __str__(self): def __str__(self):
return self.cid return self.cid
@ -162,7 +160,7 @@ class CircuitTermination(
term_side = models.CharField( term_side = models.CharField(
max_length=1, max_length=1,
choices=CircuitTerminationSideChoices, choices=CircuitTerminationSideChoices,
verbose_name='Termination' verbose_name=_('termination')
) )
site = models.ForeignKey( site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
@ -179,30 +177,31 @@ class CircuitTermination(
null=True null=True
) )
port_speed = models.PositiveIntegerField( port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)', verbose_name=_('port speed (Kbps)'),
blank=True, blank=True,
null=True, null=True,
help_text=_("Physical circuit speed") help_text=_('Physical circuit speed')
) )
upstream_speed = models.PositiveIntegerField( upstream_speed = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Upstream speed (Kbps)', verbose_name=_('upstream speed (Kbps)'),
help_text=_('Upstream speed, if different from port speed') help_text=_('Upstream speed, if different from port speed')
) )
xconnect_id = models.CharField( xconnect_id = models.CharField(
max_length=50, max_length=50,
blank=True, blank=True,
verbose_name='Cross-connect ID', verbose_name=_('cross-connect ID'),
help_text=_("ID of the local cross-connect") help_text=_('ID of the local cross-connect')
) )
pp_info = models.CharField( pp_info = models.CharField(
max_length=100, max_length=100,
blank=True, blank=True,
verbose_name='Patch panel/port(s)', verbose_name=_('patch panel/port(s)'),
help_text=_("Patch panel ID and port number(s)") help_text=_('Patch panel ID and port number(s)')
) )
description = models.CharField( description = models.CharField(
verbose_name=_('description'),
max_length=200, max_length=200,
blank=True blank=True
) )
@ -215,6 +214,8 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side' name='%(app_label)s_%(class)s_unique_circuit_term_side'
), ),
) )
verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations')
def __str__(self): def __str__(self):
return f'{self.circuit}: Termination {self.term_side}' return f'{self.circuit}: Termination {self.term_side}'

View File

@ -1,10 +1,10 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from netbox.models.features import ContactsMixin
__all__ = ( __all__ = (
'ProviderNetwork', 'ProviderNetwork',
@ -13,17 +13,19 @@ __all__ = (
) )
class Provider(PrimaryModel): class Provider(ContactsMixin, PrimaryModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider. stores information pertinent to the user's relationship with the Provider.
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=100, max_length=100,
unique=True, unique=True,
help_text=_("Full name of the provider") help_text=_('Full name of the provider')
) )
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'),
max_length=100, max_length=100,
unique=True unique=True
) )
@ -33,15 +35,12 @@ class Provider(PrimaryModel):
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
clone_fields = () clone_fields = ()
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
verbose_name = _('provider')
verbose_name_plural = _('providers')
def __str__(self): def __str__(self):
return self.name return self.name
@ -50,7 +49,7 @@ class Provider(PrimaryModel):
return reverse('circuits:provider', args=[self.pk]) return reverse('circuits:provider', args=[self.pk])
class ProviderAccount(PrimaryModel): class ProviderAccount(ContactsMixin, PrimaryModel):
""" """
This is a discrete account within a provider. Each Circuit belongs to a Provider Account. This is a discrete account within a provider. Each Circuit belongs to a Provider Account.
""" """
@ -61,18 +60,14 @@ class ProviderAccount(PrimaryModel):
) )
account = models.CharField( account = models.CharField(
max_length=100, max_length=100,
verbose_name='Account ID' verbose_name=_('account ID')
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=100, max_length=100,
blank=True blank=True
) )
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
clone_fields = ('provider', ) clone_fields = ('provider', )
class Meta: class Meta:
@ -88,6 +83,8 @@ class ProviderAccount(PrimaryModel):
condition=~Q(name="") condition=~Q(name="")
), ),
) )
verbose_name = _('provider account')
verbose_name_plural = _('provider accounts')
def __str__(self): def __str__(self):
if self.name: if self.name:
@ -104,6 +101,7 @@ class ProviderNetwork(PrimaryModel):
unimportant to the user. unimportant to the user.
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=100 max_length=100
) )
provider = models.ForeignKey( provider = models.ForeignKey(
@ -114,7 +112,7 @@ class ProviderNetwork(PrimaryModel):
service_id = models.CharField( service_id = models.CharField(
max_length=100, max_length=100,
blank=True, blank=True,
verbose_name='Service ID' verbose_name=_('service ID')
) )
class Meta: class Meta:
@ -125,6 +123,8 @@ class ProviderNetwork(PrimaryModel):
name='%(app_label)s_%(class)s_unique_provider_name' name='%(app_label)s_%(class)s_unique_provider_name'
), ),
) )
verbose_name = _('provider network')
verbose_name_plural = _('provider networks')
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,3 +1,4 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from circuits.models import * from circuits.models import *
@ -24,7 +25,8 @@ CIRCUITTERMINATION_LINK = """
class CircuitTypeTable(NetBoxTable): class CircuitTypeTable(NetBoxTable):
name = tables.Column( name = tables.Column(
linkify=True linkify=True,
verbose_name=_('Name'),
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:circuittype_list' url_name='circuits:circuittype_list'
@ -32,7 +34,7 @@ class CircuitTypeTable(NetBoxTable):
circuit_count = columns.LinkedCountColumn( circuit_count = columns.LinkedCountColumn(
viewname='circuits:circuit_list', viewname='circuits:circuit_list',
url_params={'type_id': 'pk'}, url_params={'type_id': 'pk'},
verbose_name='Circuits' verbose_name=_('Circuits')
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
@ -46,28 +48,31 @@ class CircuitTypeTable(NetBoxTable):
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column( cid = tables.Column(
linkify=True, linkify=True,
verbose_name='Circuit ID' verbose_name=_('Circuit ID')
) )
provider = tables.Column( provider = tables.Column(
verbose_name=_('Provider'),
linkify=True linkify=True
) )
provider_account = tables.Column( provider_account = tables.Column(
linkify=True, linkify=True,
verbose_name='Account' verbose_name=_('Account')
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn( termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side A' verbose_name=_('Side A')
) )
termination_z = tables.TemplateColumn( termination_z = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name=_('Side Z')
) )
commit_rate = CommitRateColumn( commit_rate = CommitRateColumn(
verbose_name='Commit Rate' verbose_name=_('Commit Rate')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
) )
comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
) )

View File

@ -1,4 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from circuits.models import * from circuits.models import *
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin from tenancy.tables import ContactsColumnMixin
@ -14,35 +15,38 @@ __all__ = (
class ProviderTable(ContactsColumnMixin, NetBoxTable): class ProviderTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'),
linkify=True linkify=True
) )
accounts = columns.ManyToManyColumn( accounts = columns.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='Accounts' verbose_name=_('Accounts')
) )
account_count = columns.LinkedCountColumn( account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'), accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list', viewname='circuits:provideraccount_list',
url_params={'account_id': 'pk'}, url_params={'account_id': 'pk'},
verbose_name='Account Count' verbose_name=_('Account Count')
) )
asns = columns.ManyToManyColumn( asns = columns.ManyToManyColumn(
linkify_item=True, linkify_item=True,
verbose_name='ASNs' verbose_name=_('ASNs')
) )
asn_count = columns.LinkedCountColumn( asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'), accessor=tables.A('asns__count'),
viewname='ipam:asn_list', viewname='ipam:asn_list',
url_params={'provider_id': 'pk'}, url_params={'provider_id': 'pk'},
verbose_name='ASN Count' verbose_name=_('ASN Count')
) )
circuit_count = columns.LinkedCountColumn( circuit_count = columns.LinkedCountColumn(
accessor=Accessor('count_circuits'), accessor=Accessor('count_circuits'),
viewname='circuits:circuit_list', viewname='circuits:circuit_list',
url_params={'provider_id': 'pk'}, url_params={'provider_id': 'pk'},
verbose_name='Circuits' verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
) )
comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:provider_list' url_name='circuits:provider_list'
) )
@ -58,19 +62,25 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
class ProviderAccountTable(ContactsColumnMixin, NetBoxTable): class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
account = tables.Column( account = tables.Column(
linkify=True linkify=True,
verbose_name=_('Account'),
)
name = tables.Column(
verbose_name=_('Name'),
) )
name = tables.Column()
provider = tables.Column( provider = tables.Column(
verbose_name=_('Provider'),
linkify=True linkify=True
) )
circuit_count = columns.LinkedCountColumn( circuit_count = columns.LinkedCountColumn(
accessor=Accessor('count_circuits'), accessor=Accessor('count_circuits'),
viewname='circuits:circuit_list', viewname='circuits:circuit_list',
url_params={'provider_account_id': 'pk'}, url_params={'provider_account_id': 'pk'},
verbose_name='Circuits' verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
) )
comments = columns.MarkdownColumn()
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:provideraccount_list' url_name='circuits:provideraccount_list'
) )
@ -86,12 +96,16 @@ class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
class ProviderNetworkTable(NetBoxTable): class ProviderNetworkTable(NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'),
linkify=True linkify=True
) )
provider = tables.Column( provider = tables.Column(
verbose_name=_('Provider'),
linkify=True linkify=True
) )
comments = columns.MarkdownColumn() comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='circuits:providernetwork_list' url_name='circuits:providernetwork_list'
) )

View File

@ -1,4 +1,4 @@
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
@ -63,12 +63,12 @@ class JobStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed' STATUS_FAILED = 'failed'
CHOICES = ( CHOICES = (
(STATUS_PENDING, 'Pending', 'cyan'), (STATUS_PENDING, _('Pending'), 'cyan'),
(STATUS_SCHEDULED, 'Scheduled', 'gray'), (STATUS_SCHEDULED, _('Scheduled'), 'gray'),
(STATUS_RUNNING, 'Running', 'blue'), (STATUS_RUNNING, _('Running'), 'blue'),
(STATUS_COMPLETED, 'Completed', 'green'), (STATUS_COMPLETED, _('Completed'), 'green'),
(STATUS_ERRORED, 'Errored', 'red'), (STATUS_ERRORED, _('Errored'), 'red'),
(STATUS_FAILED, 'Failed', 'red'), (STATUS_FAILED, _('Failed'), 'red'),
) )
TERMINAL_STATE_CHOICES = ( TERMINAL_STATE_CHOICES = (

View File

@ -6,13 +6,9 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import boto3
from botocore.config import Config as Boto3Config
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dulwich import porcelain
from dulwich.config import ConfigDict
from netbox.registry import registry from netbox.registry import registry
from .choices import DataSourceTypeChoices from .choices import DataSourceTypeChoices
@ -43,9 +39,20 @@ class DataBackend:
parameters = {} parameters = {}
sensitive_parameters = [] sensitive_parameters = []
# Prevent Django's template engine from calling the backend
# class when referenced via DataSource.backend_class
do_not_call_in_templates = True
def __init__(self, url, **kwargs): def __init__(self, url, **kwargs):
self.url = url self.url = url
self.params = kwargs self.params = kwargs
self.config = self.init_config()
def init_config(self):
"""
Hook to initialize the instance's configuration.
"""
return
@property @property
def url_scheme(self): def url_scheme(self):
@ -58,6 +65,7 @@ class DataBackend:
@register_backend(DataSourceTypeChoices.LOCAL) @register_backend(DataSourceTypeChoices.LOCAL)
class LocalBackend(DataBackend): class LocalBackend(DataBackend):
@contextmanager @contextmanager
def fetch(self): def fetch(self):
logger.debug(f"Data source type is local; skipping fetch") logger.debug(f"Data source type is local; skipping fetch")
@ -89,14 +97,28 @@ class GitBackend(DataBackend):
} }
sensitive_parameters = ['password'] sensitive_parameters = ['password']
def init_config(self):
from dulwich.config import ConfigDict
# Initialize backend config
config = ConfigDict()
# Apply HTTP proxy (if configured)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
return config
@contextmanager @contextmanager
def fetch(self): def fetch(self):
from dulwich import porcelain
local_path = tempfile.TemporaryDirectory() local_path = tempfile.TemporaryDirectory()
config = ConfigDict()
clone_args = { clone_args = {
"branch": self.params.get('branch'), "branch": self.params.get('branch'),
"config": config, "config": self.config,
"depth": 1, "depth": 1,
"errstream": porcelain.NoneStream(), "errstream": porcelain.NoneStream(),
"quiet": True, "quiet": True,
@ -111,10 +133,6 @@ class GitBackend(DataBackend):
} }
) )
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
logger.debug(f"Cloning git repo: {self.url}") logger.debug(f"Cloning git repo: {self.url}")
try: try:
porcelain.clone(self.url, local_path.name, **clone_args) porcelain.clone(self.url, local_path.name, **clone_args)
@ -142,15 +160,20 @@ class S3Backend(DataBackend):
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com' REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'
@contextmanager def init_config(self):
def fetch(self): from botocore.config import Config as Boto3Config
local_path = tempfile.TemporaryDirectory()
# Build the S3 configuration # Initialize backend config
s3_config = Boto3Config( return Boto3Config(
proxies=settings.HTTP_PROXIES, proxies=settings.HTTP_PROXIES,
) )
@contextmanager
def fetch(self):
import boto3
local_path = tempfile.TemporaryDirectory()
# Initialize the S3 resource and bucket # Initialize the S3 resource and bucket
aws_access_key_id = self.params.get('aws_access_key_id') aws_access_key_id = self.params.get('aws_access_key_id')
aws_secret_access_key = self.params.get('aws_secret_access_key') aws_secret_access_key = self.params.get('aws_secret_access_key')
@ -159,7 +182,7 @@ class S3Backend(DataBackend):
region_name=self._region_name, region_name=self._region_name,
aws_access_key_id=aws_access_key_id, aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key, aws_secret_access_key=aws_secret_access_key,
config=s3_config config=self.config
) )
bucket = s3.Bucket(self._bucket_name) bucket = s3.Bucket(self._bucket_name)

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from core.choices import DataSourceTypeChoices from core.choices import DataSourceTypeChoices
from core.models import * from core.models import *
@ -15,6 +15,7 @@ __all__ = (
class DataSourceBulkEditForm(NetBoxModelBulkEditForm): class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(DataSourceTypeChoices), choices=add_blank_choice(DataSourceTypeChoices),
required=False, required=False,
initial='' initial=''
@ -25,16 +26,17 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
label=_('Enforce unique space') label=_('Enforce unique space')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=200, max_length=200,
required=False required=False
) )
comments = CommentField( comments = CommentField()
label=_('Comments')
)
parameters = forms.JSONField( parameters = forms.JSONField(
label=_('Parameters'),
required=False required=False
) )
ignore_rules = forms.CharField( ignore_rules = forms.CharField(
label=_('Ignore rules'),
required=False, required=False,
widget=forms.Textarea() widget=forms.Textarea()
) )

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from core.choices import * from core.choices import *
from core.models import * from core.models import *
@ -23,17 +23,20 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
model = DataSource model = DataSource
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('Data Source', ('type', 'status')), (_('Data Source'), ('type', 'status')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=DataSourceTypeChoices, choices=DataSourceTypeChoices,
required=False required=False
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=DataSourceStatusChoices, choices=DataSourceStatusChoices,
required=False required=False
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
@ -45,7 +48,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
model = DataFile model = DataFile
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('File', ('source_id',)), (_('File'), ('source_id',)),
) )
source_id = DynamicModelMultipleChoiceField( source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(), queryset=DataSource.objects.all(),
@ -57,8 +60,8 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
class JobFilterForm(SavedFiltersMixin, FilterForm): class JobFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (None, ('q', 'filter_id')),
('Attributes', ('object_type', 'status')), (_('Attributes'), ('object_type', 'status')),
('Creation', ( (_('Creation'), (
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', 'started__after', 'completed__before', 'completed__after', 'user',
)), )),
@ -69,43 +72,52 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=JobStatusChoices, choices=JobStatusChoices,
required=False required=False
) )
created__after = forms.DateTimeField( created__after = forms.DateTimeField(
label=_('Created after'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
created__before = forms.DateTimeField( created__before = forms.DateTimeField(
label=_('Created before'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
scheduled__after = forms.DateTimeField( scheduled__after = forms.DateTimeField(
label=_('Scheduled after'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
scheduled__before = forms.DateTimeField( scheduled__before = forms.DateTimeField(
label=_('Scheduled before'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
started__after = forms.DateTimeField( started__after = forms.DateTimeField(
label=_('Started after'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
started__before = forms.DateTimeField( started__before = forms.DateTimeField(
label=_('Started before'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
completed__after = forms.DateTimeField( completed__after = forms.DateTimeField(
label=_('Completed after'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
completed__before = forms.DateTimeField( completed__before = forms.DateTimeField(
label=_('Completed before'),
required=False, required=False,
widget=DateTimePicker() widget=DateTimePicker()
) )
user = DynamicModelMultipleChoiceField( user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from core.models import DataFile, DataSource from core.models import DataFile, DataSource
from utilities.forms.fields import DynamicModelChoiceField from utilities.forms.fields import DynamicModelChoiceField

View File

@ -1,6 +1,7 @@
import copy import copy
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from core.models import * from core.models import *
@ -38,11 +39,11 @@ class DataSourceForm(NetBoxModelForm):
@property @property
def fieldsets(self): def fieldsets(self):
fieldsets = [ fieldsets = [
('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')), (_('Source'), ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
] ]
if self.backend_fields: if self.backend_fields:
fieldsets.append( fieldsets.append(
('Backend Parameters', self.backend_fields) (_('Backend Parameters'), self.backend_fields)
) )
return fieldsets return fieldsets
@ -79,8 +80,8 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('File Upload', ('upload_file',)), (_('File Upload'), ('upload_file',)),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')), (_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
) )
class Meta: class Meta:

View File

@ -3,9 +3,15 @@ from django.conf import settings
from django.core.management.base import CommandError from django.core.management.base import CommandError
from django.core.management.commands.makemigrations import Command as _Command from django.core.management.commands.makemigrations import Command as _Command
from django.db import models from django.db import models
from django.db.migrations.operations import AlterModelOptions
from utilities.migration import custom_deconstruct from utilities.migration import custom_deconstruct
# Monkey patch AlterModelOptions to ignore verbose name attributes
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
# Set our custom deconstructor for fields
models.Field.deconstruct = custom_deconstruct models.Field.deconstruct = custom_deconstruct

View File

@ -5,7 +5,7 @@ import sys
from django import get_version from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@ -60,7 +60,7 @@ class Command(BaseCommand):
# Additional objects to include # Additional objects to include
namespace['ContentType'] = ContentType namespace['ContentType'] = ContentType
namespace['User'] = User namespace['User'] = get_user_model()
# Load convenience commands # Load convenience commands
namespace.update({ namespace.update({

View File

@ -39,10 +39,12 @@ class DataSource(JobsMixin, PrimaryModel):
A remote source, such as a git repository, from which DataFiles are synchronized. A remote source, such as a git repository, from which DataFiles are synchronized.
""" """
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=100, max_length=100,
unique=True unique=True
) )
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=DataSourceTypeChoices, choices=DataSourceTypeChoices,
default=DataSourceTypeChoices.LOCAL default=DataSourceTypeChoices.LOCAL
@ -52,23 +54,28 @@ class DataSource(JobsMixin, PrimaryModel):
verbose_name=_('URL') verbose_name=_('URL')
) )
status = models.CharField( status = models.CharField(
verbose_name=_('status'),
max_length=50, max_length=50,
choices=DataSourceStatusChoices, choices=DataSourceStatusChoices,
default=DataSourceStatusChoices.NEW, default=DataSourceStatusChoices.NEW,
editable=False editable=False
) )
enabled = models.BooleanField( enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True default=True
) )
ignore_rules = models.TextField( ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True, blank=True,
help_text=_("Patterns (one per line) matching files to ignore when syncing") help_text=_("Patterns (one per line) matching files to ignore when syncing")
) )
parameters = models.JSONField( parameters = models.JSONField(
verbose_name=_('parameters'),
blank=True, blank=True,
null=True null=True
) )
last_synced = models.DateTimeField( last_synced = models.DateTimeField(
verbose_name=_('last synced'),
blank=True, blank=True,
null=True, null=True,
editable=False editable=False
@ -76,6 +83,8 @@ class DataSource(JobsMixin, PrimaryModel):
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
verbose_name = _('data source')
verbose_name_plural = _('data sources')
def __str__(self): def __str__(self):
return f'{self.name}' return f'{self.name}'
@ -97,6 +106,10 @@ class DataSource(JobsMixin, PrimaryModel):
def url_scheme(self): def url_scheme(self):
return urlparse(self.source_url).scheme.lower() return urlparse(self.source_url).scheme.lower()
@property
def backend_class(self):
return registry['data_backends'].get(self.type)
@property @property
def is_local(self): def is_local(self):
return self.type == DataSourceTypeChoices.LOCAL return self.type == DataSourceTypeChoices.LOCAL
@ -132,17 +145,15 @@ class DataSource(JobsMixin, PrimaryModel):
) )
def get_backend(self): def get_backend(self):
backend_cls = registry['data_backends'].get(self.type)
backend_params = self.parameters or {} backend_params = self.parameters or {}
return self.backend_class(self.source_url, **backend_params)
return backend_cls(self.source_url, **backend_params)
def sync(self): def sync(self):
""" """
Create/update/delete child DataFiles as necessary to synchronize with the remote source. Create/update/delete child DataFiles as necessary to synchronize with the remote source.
""" """
if self.status == DataSourceStatusChoices.SYNCING: if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError(f"Cannot initiate sync; syncing already in progress.") raise SyncError("Cannot initiate sync; syncing already in progress.")
# Emit the pre_sync signal # Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self) pre_sync.send(sender=self.__class__, instance=self)
@ -151,7 +162,12 @@ class DataSource(JobsMixin, PrimaryModel):
DataSource.objects.filter(pk=self.pk).update(status=self.status) DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Replicate source data locally # Replicate source data locally
try:
backend = self.get_backend() backend = self.get_backend()
except ModuleNotFoundError as e:
raise SyncError(
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
)
with backend.fetch() as local_path: with backend.fetch() as local_path:
logger.debug(f'Syncing files from source root {local_path}') logger.debug(f'Syncing files from source root {local_path}')
@ -200,6 +216,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Emit the post_sync signal # Emit the post_sync signal
post_sync.send(sender=self.__class__, instance=self) post_sync.send(sender=self.__class__, instance=self)
sync.alters_data = True
def _walk(self, root): def _walk(self, root):
""" """
@ -238,9 +255,11 @@ class DataFile(models.Model):
updated, or deleted only by calling DataSource.sync(). updated, or deleted only by calling DataSource.sync().
""" """
created = models.DateTimeField( created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True auto_now_add=True
) )
last_updated = models.DateTimeField( last_updated = models.DateTimeField(
verbose_name=_('last updated'),
editable=False editable=False
) )
source = models.ForeignKey( source = models.ForeignKey(
@ -250,20 +269,23 @@ class DataFile(models.Model):
editable=False editable=False
) )
path = models.CharField( path = models.CharField(
verbose_name=_('path'),
max_length=1000, max_length=1000,
editable=False, editable=False,
help_text=_("File path relative to the data source's root") help_text=_("File path relative to the data source's root")
) )
size = models.PositiveIntegerField( size = models.PositiveIntegerField(
editable=False editable=False,
verbose_name=_('size')
) )
hash = models.CharField( hash = models.CharField(
verbose_name=_('hash'),
max_length=64, max_length=64,
editable=False, editable=False,
validators=[ validators=[
RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters.")) RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
], ],
help_text=_("SHA256 hash of the file data") help_text=_('SHA256 hash of the file data')
) )
data = models.BinaryField() data = models.BinaryField()
@ -280,6 +302,8 @@ class DataFile(models.Model):
indexes = [ indexes = [
models.Index(fields=('source', 'path'), name='core_datafile_source_path'), models.Index(fields=('source', 'path'), name='core_datafile_source_path'),
] ]
verbose_name = _('data file')
verbose_name_plural = _('data files')
def __str__(self): def __str__(self):
return self.path return self.path
@ -289,8 +313,10 @@ class DataFile(models.Model):
@property @property
def data_as_string(self): def data_as_string(self):
if not self.data:
return None
try: try:
return self.data.tobytes().decode('utf-8') return bytes(self.data, 'utf-8')
except UnicodeDecodeError: except UnicodeDecodeError:
return None return None
@ -361,3 +387,5 @@ class AutoSyncRecord(models.Model):
indexes = ( indexes = (
models.Index(fields=('object_type', 'object_id')), models.Index(fields=('object_type', 'object_id')),
) )
verbose_name = _('auto sync record')
verbose_name_plural = _('auto sync records')

View File

@ -23,20 +23,24 @@ class ManagedFile(SyncedDataMixin, models.Model):
to provide additional functionality. to provide additional functionality.
""" """
created = models.DateTimeField( created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True auto_now_add=True
) )
last_updated = models.DateTimeField( last_updated = models.DateTimeField(
verbose_name=_('last updated'),
editable=False, editable=False,
blank=True, blank=True,
null=True null=True
) )
file_root = models.CharField( file_root = models.CharField(
verbose_name=_('file root'),
max_length=1000, max_length=1000,
choices=ManagedFileRootPathChoices choices=ManagedFileRootPathChoices
) )
file_path = models.FilePathField( file_path = models.FilePathField(
verbose_name=_('file path'),
editable=False, editable=False,
help_text=_("File path relative to the designated root path") help_text=_('File path relative to the designated root path')
) )
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@ -52,6 +56,8 @@ class ManagedFile(SyncedDataMixin, models.Model):
indexes = [ indexes = [
models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'), models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
] ]
verbose_name = _('managed file')
verbose_name_plural = _('managed files')
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,7 +1,7 @@
import uuid import uuid
import django_rq import django_rq
from django.contrib.auth.models import User from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -43,48 +43,57 @@ class Job(models.Model):
for_concrete_model=False for_concrete_model=False
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=200 max_length=200
) )
created = models.DateTimeField( created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True auto_now_add=True
) )
scheduled = models.DateTimeField( scheduled = models.DateTimeField(
verbose_name=_('scheduled'),
null=True, null=True,
blank=True blank=True
) )
interval = models.PositiveIntegerField( interval = models.PositiveIntegerField(
verbose_name=_('interval'),
blank=True, blank=True,
null=True, null=True,
validators=( validators=(
MinValueValidator(1), MinValueValidator(1),
), ),
help_text=_("Recurrence interval (in minutes)") help_text=_('Recurrence interval (in minutes)')
) )
started = models.DateTimeField( started = models.DateTimeField(
verbose_name=_('started'),
null=True, null=True,
blank=True blank=True
) )
completed = models.DateTimeField( completed = models.DateTimeField(
verbose_name=_('completed'),
null=True, null=True,
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=User, to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
blank=True, blank=True,
null=True null=True
) )
status = models.CharField( status = models.CharField(
verbose_name=_('status'),
max_length=30, max_length=30,
choices=JobStatusChoices, choices=JobStatusChoices,
default=JobStatusChoices.STATUS_PENDING default=JobStatusChoices.STATUS_PENDING
) )
data = models.JSONField( data = models.JSONField(
verbose_name=_('data'),
null=True, null=True,
blank=True blank=True
) )
job_id = models.UUIDField( job_id = models.UUIDField(
verbose_name=_('job ID'),
unique=True unique=True
) )
@ -92,6 +101,8 @@ class Job(models.Model):
class Meta: class Meta:
ordering = ['-created'] ordering = ['-created']
verbose_name = _('job')
verbose_name_plural = _('jobs')
def __str__(self): def __str__(self):
return str(self.job_id) return str(self.job_id)

View File

@ -1,3 +1,4 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables import django_tables2 as tables
from core.models import * from core.models import *
@ -11,11 +12,18 @@ __all__ = (
class DataSourceTable(NetBoxTable): class DataSourceTable(NetBoxTable):
name = tables.Column( name = tables.Column(
verbose_name=_('Name'),
linkify=True linkify=True
) )
type = columns.ChoiceFieldColumn() type = columns.ChoiceFieldColumn(
status = columns.ChoiceFieldColumn() verbose_name=_('Type'),
enabled = columns.BooleanColumn() )
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='core:datasource_list' url_name='core:datasource_list'
) )
@ -34,12 +42,16 @@ class DataSourceTable(NetBoxTable):
class DataFileTable(NetBoxTable): class DataFileTable(NetBoxTable):
source = tables.Column( source = tables.Column(
verbose_name=_('Source'),
linkify=True linkify=True
) )
path = tables.Column( path = tables.Column(
verbose_name=_('Path'),
linkify=True linkify=True
) )
last_updated = columns.DateTimeColumn() last_updated = columns.DateTimeColumn(
verbose_name=_('Last updated'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )

View File

@ -1,5 +1,5 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from ..models import Job from ..models import Job
@ -7,23 +7,38 @@ from ..models import Job
class JobTable(NetBoxTable): class JobTable(NetBoxTable):
id = tables.Column( id = tables.Column(
verbose_name=_('ID'),
linkify=True linkify=True
) )
name = tables.Column( name = tables.Column(
verbose_name=_('Name'),
linkify=True linkify=True
) )
object_type = columns.ContentTypeColumn( object_type = columns.ContentTypeColumn(
verbose_name=_('Type') verbose_name=_('Type')
) )
object = tables.Column( object = tables.Column(
verbose_name=_('Object'),
linkify=True linkify=True
) )
status = columns.ChoiceFieldColumn() status = columns.ChoiceFieldColumn(
created = columns.DateTimeColumn() verbose_name=_('Status'),
scheduled = columns.DateTimeColumn() )
interval = columns.DurationColumn() created = columns.DateTimeColumn(
started = columns.DateTimeColumn() verbose_name=_('Created'),
completed = columns.DateTimeColumn() )
scheduled = columns.DateTimeColumn(
verbose_name=_('Scheduled'),
)
interval = columns.DurationColumn(
verbose_name=_('Interval'),
)
started = columns.DateTimeColumn(
verbose_name=_('Started'),
)
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )

View File

@ -25,4 +25,7 @@ urlpatterns = (
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'), path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'), path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
# Configuration
path('config/', views.ConfigView.as_view(), name='config'),
) )

View File

@ -1,6 +1,7 @@
from django.contrib import messages from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from extras.models import ConfigRevision
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
from utilities.utils import count_related from utilities.utils import count_related
@ -141,3 +142,14 @@ class JobBulkDeleteView(generic.BulkDeleteView):
queryset = Job.objects.all() queryset = Job.objects.all()
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
table = tables.JobTable table = tables.JobTable
#
# Config Revisions
#
class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
return self.queryset.first()

View File

@ -214,9 +214,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack model = Rack
fields = [ fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight', 'weight_unit',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
] ]
@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True) weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
console_server_port_template_count = serializers.IntegerField(read_only=True)
power_port_template_count = serializers.IntegerField(read_only=True)
power_outlet_template_count = serializers.IntegerField(read_only=True)
interface_template_count = serializers.IntegerField(read_only=True)
front_port_template_count = serializers.IntegerField(read_only=True)
rear_port_template_count = serializers.IntegerField(read_only=True)
device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
] ]
@ -498,12 +514,18 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
allow_blank=True, allow_blank=True,
allow_null=True allow_null=True
) )
rf_role = ChoiceField(
choices=WirelessRoleChoices,
required=False,
allow_blank=True,
allow_null=True
)
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'created', 'last_updated', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
] ]
@ -635,15 +657,16 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
] ]
class DeviceSerializer(NetBoxModelSerializer): class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer() role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -663,19 +686,35 @@ class DeviceSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None) vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
# Counter fields
console_port_count = serializers.IntegerField(read_only=True)
console_server_port_count = serializers.IntegerField(read_only=True)
power_port_count = serializers.IntegerField(read_only=True)
power_outlet_count = serializers.IntegerField(read_only=True)
interface_count = serializers.IntegerField(read_only=True)
front_port_count = serializers.IntegerField(read_only=True)
rear_port_count = serializers.IntegerField(read_only=True)
device_bay_count = serializers.IntegerField(read_only=True)
module_bay_count = serializers.IntegerField(read_only=True)
inventory_item_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(NestedDeviceSerializer) @extend_schema_field(NestedDeviceSerializer)
@ -689,17 +728,22 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data return data
def get_device_role(self, obj):
return obj.role
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template', 'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
'created', 'last_updated', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
] ]
@extend_schema_field(serializers.JSONField(allow_null=True)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -996,7 +1040,8 @@ class ModuleBaySerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields', 'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields',
'created', 'last_updated', 'created', 'last_updated',
] ]
@ -1139,13 +1184,15 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False, allow_null=True, default=None) master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
# Counter fields
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = [ fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'member_count', 'created', 'last_updated', 'created', 'last_updated', 'member_count',
] ]
@ -1195,6 +1242,10 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
choices=PowerFeedPhaseChoices, choices=PowerFeedPhaseChoices,
default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE, default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
) )
tenant = NestedTenantSerializer(
required=False,
allow_null=True
)
class Meta: class Meta:
model = PowerFeed model = PowerFeed
@ -1202,5 +1253,5 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]

View File

@ -362,7 +362,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet):
class DeviceRoleViewSet(NetBoxModelViewSet): class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate( queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
device_count=count_related(Device, 'device_role'), device_count=count_related(Device, 'role'),
virtualmachine_count=count_related(VirtualMachine, 'role') virtualmachine_count=count_related(VirtualMachine, 'role')
) )
serializer_class = serializers.DeviceRoleSerializer serializer_class = serializers.DeviceRoleSerializer
@ -393,7 +393,7 @@ class DeviceViewSet(
NetBoxModelViewSet NetBoxModelViewSet
): ):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'device_type__manufacturer', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
) )
filterset_class = filtersets.DeviceFilterSet filterset_class = filtersets.DeviceFilterSet
@ -579,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet):
# #
class VirtualChassisViewSet(NetBoxModelViewSet): class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate( queryset = VirtualChassis.objects.prefetch_related('tags')
member_count=count_related(Device, 'virtual_chassis')
)
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer
filterset_class = filtersets.VirtualChassisFilterSet filterset_class = filtersets.VirtualChassisFilterSet
brief_prefetch_fields = ['master'] brief_prefetch_fields = ['master']

View File

@ -9,7 +9,8 @@ class DCIMConfig(AppConfig):
def ready(self): def ready(self):
from . import signals, search from . import signals, search
from .models import CableTermination from .models import CableTermination, Device, DeviceType, VirtualChassis
from utilities.counters import connect_counters
# Register denormalized fields # Register denormalized fields
denormalized.register(CableTermination, '_device', { denormalized.register(CableTermination, '_device', {
@ -24,3 +25,6 @@ class DCIMConfig(AppConfig):
denormalized.register(CableTermination, '_location', { denormalized.register(CableTermination, '_location', {
'_site': 'site', '_site': 'site',
}) })
# Register counters
connect_counters(Device, DeviceType, VirtualChassis)

View File

@ -1,3 +1,5 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet from utilities.choices import ChoiceSet
@ -15,11 +17,11 @@ class SiteStatusChoices(ChoiceSet):
STATUS_RETIRED = 'retired' STATUS_RETIRED = 'retired'
CHOICES = [ CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'), (STATUS_STAGING, _('Staging'), 'blue'),
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
(STATUS_RETIRED, 'Retired', 'red'), (STATUS_RETIRED, _('Retired'), 'red'),
] ]
@ -60,13 +62,13 @@ class RackTypeChoices(ChoiceSet):
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical' TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
CHOICES = ( CHOICES = (
(TYPE_2POST, '2-post frame'), (TYPE_2POST, _('2-post frame')),
(TYPE_4POST, '4-post frame'), (TYPE_4POST, _('4-post frame')),
(TYPE_CABINET, '4-post cabinet'), (TYPE_CABINET, _('4-post cabinet')),
(TYPE_WALLFRAME, 'Wall-mounted frame'), (TYPE_WALLFRAME, _('Wall-mounted frame')),
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'), (TYPE_WALLFRAME_VERTICAL, _('Wall-mounted frame (vertical)')),
(TYPE_WALLCABINET, 'Wall-mounted cabinet'), (TYPE_WALLCABINET, _('Wall-mounted cabinet')),
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'), (TYPE_WALLCABINET_VERTICAL, _('Wall-mounted cabinet (vertical)')),
) )
@ -78,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
WIDTH_23IN = 23 WIDTH_23IN = 23
CHOICES = ( CHOICES = (
(WIDTH_10IN, '10 inches'), (WIDTH_10IN, _('10 inches')),
(WIDTH_19IN, '19 inches'), (WIDTH_19IN, _('19 inches')),
(WIDTH_21IN, '21 inches'), (WIDTH_21IN, _('21 inches')),
(WIDTH_23IN, '23 inches'), (WIDTH_23IN, _('23 inches')),
) )
@ -95,11 +97,11 @@ class RackStatusChoices(ChoiceSet):
STATUS_DEPRECATED = 'deprecated' STATUS_DEPRECATED = 'deprecated'
CHOICES = [ CHOICES = [
(STATUS_RESERVED, 'Reserved', 'yellow'), (STATUS_RESERVED, _('Reserved'), 'yellow'),
(STATUS_AVAILABLE, 'Available', 'green'), (STATUS_AVAILABLE, _('Available'), 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_ACTIVE, 'Active', 'blue'), (STATUS_ACTIVE, _('Active'), 'blue'),
(STATUS_DEPRECATED, 'Deprecated', 'red'), (STATUS_DEPRECATED, _('Deprecated'), 'red'),
] ]
@ -109,8 +111,8 @@ class RackDimensionUnitChoices(ChoiceSet):
UNIT_INCH = 'in' UNIT_INCH = 'in'
CHOICES = ( CHOICES = (
(UNIT_MILLIMETER, 'Millimeters'), (UNIT_MILLIMETER, _('Millimeters')),
(UNIT_INCH, 'Inches'), (UNIT_INCH, _('Inches')),
) )
@ -135,8 +137,8 @@ class SubdeviceRoleChoices(ChoiceSet):
ROLE_CHILD = 'child' ROLE_CHILD = 'child'
CHOICES = ( CHOICES = (
(ROLE_PARENT, 'Parent'), (ROLE_PARENT, _('Parent')),
(ROLE_CHILD, 'Child'), (ROLE_CHILD, _('Child')),
) )
@ -150,8 +152,8 @@ class DeviceFaceChoices(ChoiceSet):
FACE_REAR = 'rear' FACE_REAR = 'rear'
CHOICES = ( CHOICES = (
(FACE_FRONT, 'Front'), (FACE_FRONT, _('Front')),
(FACE_REAR, 'Rear'), (FACE_REAR, _('Rear')),
) )
@ -167,13 +169,13 @@ class DeviceStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONING = 'decommissioning' STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = [ CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'), (STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGED, 'Staged', 'blue'), (STATUS_STAGED, _('Staged'), 'blue'),
(STATUS_FAILED, 'Failed', 'red'), (STATUS_FAILED, _('Failed'), 'red'),
(STATUS_INVENTORY, 'Inventory', 'purple'), (STATUS_INVENTORY, _('Inventory'), 'purple'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
] ]
@ -188,13 +190,13 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_MIXED = 'mixed' AIRFLOW_MIXED = 'mixed'
CHOICES = ( CHOICES = (
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'), (AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, 'Rear to front'), (AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
(AIRFLOW_LEFT_TO_RIGHT, 'Left to right'), (AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'), (AIRFLOW_RIGHT_TO_LEFT, _('Right to left')),
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'), (AIRFLOW_SIDE_TO_REAR, _('Side to rear')),
(AIRFLOW_PASSIVE, 'Passive'), (AIRFLOW_PASSIVE, _('Passive')),
(AIRFLOW_MIXED, 'Mixed'), (AIRFLOW_MIXED, _('Mixed')),
) )
@ -213,12 +215,12 @@ class ModuleStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONING = 'decommissioning' STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = [ CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'), (STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGED, 'Staged', 'blue'), (STATUS_STAGED, _('Staged'), 'blue'),
(STATUS_FAILED, 'Failed', 'red'), (STATUS_FAILED, _('Failed'), 'red'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
] ]
@ -438,7 +440,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'), (TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'), (TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)), )),
('NEMA (Non-locking)', ( (_('NEMA (Non-locking)'), (
(TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'),
(TYPE_NEMA_520P, 'NEMA 5-20P'), (TYPE_NEMA_520P, 'NEMA 5-20P'),
@ -460,7 +462,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_1550P, 'NEMA 15-50P'), (TYPE_NEMA_1550P, 'NEMA 15-50P'),
(TYPE_NEMA_1560P, 'NEMA 15-60P'), (TYPE_NEMA_1560P, 'NEMA 15-60P'),
)), )),
('NEMA (Locking)', ( (_('NEMA (Locking)'), (
(TYPE_NEMA_L115P, 'NEMA L1-15P'), (TYPE_NEMA_L115P, 'NEMA L1-15P'),
(TYPE_NEMA_L515P, 'NEMA L5-15P'), (TYPE_NEMA_L515P, 'NEMA L5-15P'),
(TYPE_NEMA_L520P, 'NEMA L5-20P'), (TYPE_NEMA_L520P, 'NEMA L5-20P'),
@ -483,7 +485,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L2130P, 'NEMA L21-30P'), (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
(TYPE_NEMA_L2230P, 'NEMA L22-30P'), (TYPE_NEMA_L2230P, 'NEMA L22-30P'),
)), )),
('California Style', ( (_('California Style'), (
(TYPE_CS6361C, 'CS6361C'), (TYPE_CS6361C, 'CS6361C'),
(TYPE_CS6365C, 'CS6365C'), (TYPE_CS6365C, 'CS6365C'),
(TYPE_CS8165C, 'CS8165C'), (TYPE_CS8165C, 'CS8165C'),
@ -491,7 +493,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_CS8365C, 'CS8365C'), (TYPE_CS8365C, 'CS8365C'),
(TYPE_CS8465C, 'CS8465C'), (TYPE_CS8465C, 'CS8465C'),
)), )),
('International/ITA', ( (_('International/ITA'), (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'), (TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'), (TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'), (TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
@ -521,7 +523,7 @@ class PowerPortTypeChoices(ChoiceSet):
('DC', ( ('DC', (
(TYPE_DC, 'DC Terminal'), (TYPE_DC, 'DC Terminal'),
)), )),
('Proprietary', ( (_('Proprietary'), (
(TYPE_SAF_D_GRID, 'Saf-D-Grid'), (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
(TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'), (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
@ -529,7 +531,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)), )),
('Other', ( (_('Other'), (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'), (TYPE_OTHER, 'Other'),
)), )),
@ -675,7 +677,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'), (TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'), (TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)), )),
('NEMA (Non-locking)', ( (_('NEMA (Non-locking)'), (
(TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'),
(TYPE_NEMA_520R, 'NEMA 5-20R'), (TYPE_NEMA_520R, 'NEMA 5-20R'),
@ -697,7 +699,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_1550R, 'NEMA 15-50R'), (TYPE_NEMA_1550R, 'NEMA 15-50R'),
(TYPE_NEMA_1560R, 'NEMA 15-60R'), (TYPE_NEMA_1560R, 'NEMA 15-60R'),
)), )),
('NEMA (Locking)', ( (_('NEMA (Locking)'), (
(TYPE_NEMA_L115R, 'NEMA L1-15R'), (TYPE_NEMA_L115R, 'NEMA L1-15R'),
(TYPE_NEMA_L515R, 'NEMA L5-15R'), (TYPE_NEMA_L515R, 'NEMA L5-15R'),
(TYPE_NEMA_L520R, 'NEMA L5-20R'), (TYPE_NEMA_L520R, 'NEMA L5-20R'),
@ -720,7 +722,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L2130R, 'NEMA L21-30R'), (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
(TYPE_NEMA_L2230R, 'NEMA L22-30R'), (TYPE_NEMA_L2230R, 'NEMA L22-30R'),
)), )),
('California Style', ( (_('California Style'), (
(TYPE_CS6360C, 'CS6360C'), (TYPE_CS6360C, 'CS6360C'),
(TYPE_CS6364C, 'CS6364C'), (TYPE_CS6364C, 'CS6364C'),
(TYPE_CS8164C, 'CS8164C'), (TYPE_CS8164C, 'CS8164C'),
@ -728,7 +730,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8364C, 'CS8364C'), (TYPE_CS8364C, 'CS8364C'),
(TYPE_CS8464C, 'CS8464C'), (TYPE_CS8464C, 'CS8464C'),
)), )),
('ITA/International', ( (_('ITA/International'), (
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'), (TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'), (TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'), (TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@ -750,7 +752,7 @@ class PowerOutletTypeChoices(ChoiceSet):
('DC', ( ('DC', (
(TYPE_DC, 'DC Terminal'), (TYPE_DC, 'DC Terminal'),
)), )),
('Proprietary', ( (_('Proprietary'), (
(TYPE_HDOT_CX, 'HDOT Cx'), (TYPE_HDOT_CX, 'HDOT Cx'),
(TYPE_SAF_D_GRID, 'Saf-D-Grid'), (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
(TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'), (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
@ -759,7 +761,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'), (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'), (TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)), )),
('Other', ( (_('Other'), (
(TYPE_HARDWIRED, 'Hardwired'), (TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'), (TYPE_OTHER, 'Other'),
)), )),
@ -789,9 +791,9 @@ class InterfaceKindChoices(ChoiceSet):
KIND_WIRELESS = 'wireless' KIND_WIRELESS = 'wireless'
CHOICES = ( CHOICES = (
(KIND_PHYSICAL, 'Physical'), (KIND_PHYSICAL, _('Physical')),
(KIND_VIRTUAL, 'Virtual'), (KIND_VIRTUAL, _('Virtual')),
(KIND_WIRELESS, 'Wireless'), (KIND_WIRELESS, _('Wireless')),
) )
@ -940,15 +942,15 @@ class InterfaceTypeChoices(ChoiceSet):
CHOICES = ( CHOICES = (
( (
'Virtual interfaces', _('Virtual interfaces'),
( (
(TYPE_VIRTUAL, 'Virtual'), (TYPE_VIRTUAL, _('Virtual')),
(TYPE_BRIDGE, 'Bridge'), (TYPE_BRIDGE, _('Bridge')),
(TYPE_LAG, 'Link Aggregation Group (LAG)'), (TYPE_LAG, _('Link Aggregation Group (LAG)')),
), ),
), ),
( (
'Ethernet (fixed)', _('Ethernet (fixed)'),
( (
(TYPE_100ME_FX, '100BASE-FX (10/100ME FIBER)'), (TYPE_100ME_FX, '100BASE-FX (10/100ME FIBER)'),
(TYPE_100ME_LFX, '100BASE-LFX (10/100ME FIBER)'), (TYPE_100ME_LFX, '100BASE-LFX (10/100ME FIBER)'),
@ -962,7 +964,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Ethernet (modular)', _('Ethernet (modular)'),
( (
(TYPE_1GE_GBIC, 'GBIC (1GE)'), (TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'), (TYPE_1GE_SFP, 'SFP (1GE)'),
@ -996,7 +998,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Ethernet (backplane)', _('Ethernet (backplane)'),
( (
(TYPE_1GE_KX, '1000BASE-KX (1GE)'), (TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'), (TYPE_10GE_KR, '10GBASE-KR (10GE)'),
@ -1010,7 +1012,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Wireless', _('Wireless'),
( (
(TYPE_80211A, 'IEEE 802.11a'), (TYPE_80211A, 'IEEE 802.11a'),
(TYPE_80211G, 'IEEE 802.11b/g'), (TYPE_80211G, 'IEEE 802.11b/g'),
@ -1024,7 +1026,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Cellular', _('Cellular'),
( (
(TYPE_GSM, 'GSM'), (TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'), (TYPE_CDMA, 'CDMA'),
@ -1071,7 +1073,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Serial', _('Serial'),
( (
(TYPE_T1, 'T1 (1.544 Mbps)'), (TYPE_T1, 'T1 (1.544 Mbps)'),
(TYPE_E1, 'E1 (2.048 Mbps)'), (TYPE_E1, 'E1 (2.048 Mbps)'),
@ -1086,7 +1088,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Coaxial', _('Coaxial'),
( (
(TYPE_DOCSIS, 'DOCSIS'), (TYPE_DOCSIS, 'DOCSIS'),
) )
@ -1103,7 +1105,7 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Stacking', _('Stacking'),
( (
(TYPE_STACKWISE, 'Cisco StackWise'), (TYPE_STACKWISE, 'Cisco StackWise'),
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
@ -1122,9 +1124,9 @@ class InterfaceTypeChoices(ChoiceSet):
) )
), ),
( (
'Other', _('Other'),
( (
(TYPE_OTHER, 'Other'), (TYPE_OTHER, _('Other')),
) )
), ),
) )
@ -1153,9 +1155,9 @@ class InterfaceDuplexChoices(ChoiceSet):
DUPLEX_AUTO = 'auto' DUPLEX_AUTO = 'auto'
CHOICES = ( CHOICES = (
(DUPLEX_HALF, 'Half'), (DUPLEX_HALF, _('Half')),
(DUPLEX_FULL, 'Full'), (DUPLEX_FULL, _('Full')),
(DUPLEX_AUTO, 'Auto'), (DUPLEX_AUTO, _('Auto')),
) )
@ -1166,9 +1168,9 @@ class InterfaceModeChoices(ChoiceSet):
MODE_TAGGED_ALL = 'tagged-all' MODE_TAGGED_ALL = 'tagged-all'
CHOICES = ( CHOICES = (
(MODE_ACCESS, 'Access'), (MODE_ACCESS, _('Access')),
(MODE_TAGGED, 'Tagged'), (MODE_TAGGED, _('Tagged')),
(MODE_TAGGED_ALL, 'Tagged (All)'), (MODE_TAGGED_ALL, _('Tagged (All)')),
) )
@ -1197,7 +1199,7 @@ class InterfacePoETypeChoices(ChoiceSet):
CHOICES = ( CHOICES = (
( (
'IEEE Standard', _('IEEE Standard'),
( (
(TYPE_1_8023AF, '802.3af (Type 1)'), (TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'), (TYPE_2_8023AT, '802.3at (Type 2)'),
@ -1206,12 +1208,12 @@ class InterfacePoETypeChoices(ChoiceSet):
) )
), ),
( (
'Passive', _('Passive'),
( (
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), (PASSIVE_24V_2PAIR, _('Passive 24V (2-pair)')),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), (PASSIVE_24V_4PAIR, _('Passive 24V (4-pair)')),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), (PASSIVE_48V_2PAIR, _('Passive 48V (2-pair)')),
(PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'), (PASSIVE_48V_4PAIR, _('Passive 48V (4-pair)')),
) )
), ),
) )
@ -1273,7 +1275,7 @@ class PortTypeChoices(ChoiceSet):
CHOICES = ( CHOICES = (
( (
'Copper', _('Copper'),
( (
(TYPE_8P8C, '8P8C'), (TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'), (TYPE_8P6C, '8P6C'),
@ -1296,7 +1298,7 @@ class PortTypeChoices(ChoiceSet):
), ),
), ),
( (
'Fiber Optic', _('Fiber Optic'),
( (
(TYPE_FC, 'FC'), (TYPE_FC, 'FC'),
(TYPE_LC, 'LC'), (TYPE_LC, 'LC'),
@ -1329,9 +1331,9 @@ class PortTypeChoices(ChoiceSet):
), ),
), ),
( (
'Other', _('Other'),
( (
(TYPE_OTHER, 'Other'), (TYPE_OTHER, _('Other')),
) )
) )
) )
@ -1369,7 +1371,7 @@ class CableTypeChoices(ChoiceSet):
CHOICES = ( CHOICES = (
( (
'Copper', ( _('Copper'), (
(TYPE_CAT3, 'CAT3'), (TYPE_CAT3, 'CAT3'),
(TYPE_CAT5, 'CAT5'), (TYPE_CAT5, 'CAT5'),
(TYPE_CAT5E, 'CAT5e'), (TYPE_CAT5E, 'CAT5e'),
@ -1385,7 +1387,7 @@ class CableTypeChoices(ChoiceSet):
), ),
), ),
( (
'Fiber', ( _('Fiber'), (
(TYPE_MMF, 'Multimode Fiber'), (TYPE_MMF, 'Multimode Fiber'),
(TYPE_MMF_OM1, 'Multimode Fiber (OM1)'), (TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
(TYPE_MMF_OM2, 'Multimode Fiber (OM2)'), (TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
@ -1398,7 +1400,7 @@ class CableTypeChoices(ChoiceSet):
(TYPE_AOC, 'Active Optical Cabling (AOC)'), (TYPE_AOC, 'Active Optical Cabling (AOC)'),
), ),
), ),
(TYPE_POWER, 'Power'), (TYPE_POWER, _('Power')),
) )
@ -1409,9 +1411,9 @@ class LinkStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONING = 'decommissioning' STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = ( CHOICES = (
(STATUS_CONNECTED, 'Connected', 'green'), (STATUS_CONNECTED, _('Connected'), 'green'),
(STATUS_PLANNED, 'Planned', 'blue'), (STATUS_PLANNED, _('Planned'), 'blue'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), (STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
) )
@ -1428,12 +1430,12 @@ class CableLengthUnitChoices(ChoiceSet):
UNIT_INCH = 'in' UNIT_INCH = 'in'
CHOICES = ( CHOICES = (
(UNIT_KILOMETER, 'Kilometers'), (UNIT_KILOMETER, _('Kilometers')),
(UNIT_METER, 'Meters'), (UNIT_METER, _('Meters')),
(UNIT_CENTIMETER, 'Centimeters'), (UNIT_CENTIMETER, _('Centimeters')),
(UNIT_MILE, 'Miles'), (UNIT_MILE, _('Miles')),
(UNIT_FOOT, 'Feet'), (UNIT_FOOT, _('Feet')),
(UNIT_INCH, 'Inches'), (UNIT_INCH, _('Inches')),
) )
@ -1448,10 +1450,10 @@ class WeightUnitChoices(ChoiceSet):
UNIT_OUNCE = 'oz' UNIT_OUNCE = 'oz'
CHOICES = ( CHOICES = (
(UNIT_KILOGRAM, 'Kilograms'), (UNIT_KILOGRAM, _('Kilograms')),
(UNIT_GRAM, 'Grams'), (UNIT_GRAM, _('Grams')),
(UNIT_POUND, 'Pounds'), (UNIT_POUND, _('Pounds')),
(UNIT_OUNCE, 'Ounces'), (UNIT_OUNCE, _('Ounces')),
) )
@ -1484,10 +1486,10 @@ class PowerFeedStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed' STATUS_FAILED = 'failed'
CHOICES = [ CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'), (STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, 'Planned', 'blue'), (STATUS_PLANNED, _('Planned'), 'blue'),
(STATUS_FAILED, 'Failed', 'red'), (STATUS_FAILED, _('Failed'), 'red'),
] ]
@ -1497,8 +1499,8 @@ class PowerFeedTypeChoices(ChoiceSet):
TYPE_REDUNDANT = 'redundant' TYPE_REDUNDANT = 'redundant'
CHOICES = ( CHOICES = (
(TYPE_PRIMARY, 'Primary', 'green'), (TYPE_PRIMARY, _('Primary'), 'green'),
(TYPE_REDUNDANT, 'Redundant', 'cyan'), (TYPE_REDUNDANT, _('Redundant'), 'cyan'),
) )
@ -1519,8 +1521,8 @@ class PowerFeedPhaseChoices(ChoiceSet):
PHASE_3PHASE = 'three-phase' PHASE_3PHASE = 'three-phase'
CHOICES = ( CHOICES = (
(PHASE_SINGLE, 'Single phase'), (PHASE_SINGLE, _('Single phase')),
(PHASE_3PHASE, 'Three-phase'), (PHASE_3PHASE, _('Three-phase')),
) )
@ -1535,7 +1537,7 @@ class VirtualDeviceContextStatusChoices(ChoiceSet):
STATUS_OFFLINE = 'offline' STATUS_OFFLINE = 'offline'
CHOICES = [ CHOICES = [
(STATUS_ACTIVE, 'Active', 'green'), (STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'), (STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_OFFLINE, 'Offline', 'red'), (STATUS_OFFLINE, _('Offline'), 'red'),
] ]

View File

@ -17,6 +17,8 @@ RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_STARTING_UNIT_DEFAULT = 1
# #
# RearPorts # RearPorts

View File

@ -6,7 +6,6 @@ from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains from .lookups import PathContains
__all__ = ( __all__ = (
'ASNField',
'MACAddressField', 'MACAddressField',
'PathField', 'PathField',
'WWNField', 'WWNField',

View File

@ -1,5 +1,5 @@
import django_filters import django_filters
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet from extras.filtersets import LocalConfigContextFilterSet
@ -323,8 +323,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit' 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'), label=_('Location (slug)'),
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -696,6 +696,9 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
poe_type = django_filters.MultipleChoiceFilter( poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices choices=InterfacePoETypeChoices
) )
rf_role = django_filters.MultipleChoiceFilter(
choices=WirelessRoleChoices
)
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
@ -811,7 +814,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = Platform model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
@ -837,12 +840,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
label=_('Device type (ID)'), label=_('Device type (ID)'),
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_role_id', field_name='role_id',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
label=_('Role (ID)'), label=_('Role (ID)'),
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='device_role__slug', field_name='role__slug',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Role (slug)'), label=_('Role (slug)'),
@ -941,6 +944,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_has_primary_ip', method='_has_primary_ip',
label=_('Has a primary IP'), label=_('Has a primary IP'),
) )
has_oob_ip = django_filters.BooleanFilter(
method='_has_oob_ip',
label=_('Has an out-of-band IP'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis', field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
@ -996,10 +1003,15 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'), label=_('Primary IPv6 (ID)'),
) )
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip',
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1020,6 +1032,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.filter(params) return queryset.filter(params)
return queryset.exclude(params) return queryset.exclude(params)
def _has_oob_ip(self, queryset, name, value):
params = Q(oob_ip__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value): def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value) return queryset.exclude(virtual_chassis__isnull=value)
@ -1233,13 +1251,13 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='model', to_field_name='model',
label=_('Device type (model)'), label=_('Device type (model)'),
) )
device_role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role', field_name='device__role',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'), label=_('Device role (ID)'),
) )
device_role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role__slug', field_name='device__role__slug',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='slug', to_field_name='slug',
label=_('Device role (slug)'), label=_('Device role (slug)'),
@ -1255,6 +1273,18 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name', to_field_name='name',
label=_('Virtual Chassis'), label=_('Virtual Chassis'),
) )
# TODO: Remove in v4.0
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1432,17 +1462,15 @@ class InterfaceFilterSet(
PathEndpointFilterSet, PathEndpointFilterSet,
CommonInterfaceFilterSet CommonInterfaceFilterSet
): ):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis virtual_chassis_member = MultiValueCharFilter(
# members method='filter_virtual_chassis_member',
device = MultiValueCharFilter(
method='filter_device',
field_name='name', field_name='name',
label=_('Device'), label=_('Virtual Chassis Interfaces for Device')
) )
device_id = MultiValueNumberFilter( virtual_chassis_member_id = MultiValueNumberFilter(
method='filter_device_id', method='filter_virtual_chassis_member',
field_name='pk', field_name='pk',
label=_('Device (ID)'), label=_('Virtual Chassis Interfaces for Device (ID)')
) )
kind = django_filters.CharFilter( kind = django_filters.CharFilter(
method='filter_kind', method='filter_kind',
@ -1510,23 +1538,11 @@ class InterfaceFilterSet(
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
] ]
def filter_device(self, queryset, name, value): def filter_virtual_chassis_member(self, queryset, name, value):
try: try:
devices = Device.objects.filter(**{'{}__in'.format(name): value})
vc_interface_ids = [] vc_interface_ids = []
for device in devices: for device in Device.objects.filter(**{f'{name}__in': value}):
vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
def filter_device_id(self, queryset, name, id_list):
# Include interfaces belonging to peer virtual chassis members
vc_interface_ids = []
try:
devices = Device.objects.filter(pk__in=id_list)
for device in devices:
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
return queryset.filter(pk__in=vc_interface_ids) return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()
@ -1862,7 +1878,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='power_panel__site__region', field_name='power_panel__site__region',

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from dcim.models import * from dcim.models import *
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from extras.forms import CustomFieldsMixin from extras.forms import CustomFieldsMixin
from extras.models import Tag from extras.models import Tag
from utilities.forms import BootstrapMixin, form_from_model from utilities.forms import BootstrapMixin, form_from_model
@ -32,10 +32,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCre
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'),
max_length=100, max_length=100,
required=False required=False
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
label=_('Tags'),
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
) )
@ -76,14 +78,14 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm( class InterfaceBulkCreateForm(
form_from_model(Interface, [ form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'
]), ]),
DeviceBulkAddComponentForm DeviceBulkAddComponentForm
): ):
model = Interface model = Interface
field_order = ( field_order = (
'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', 'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags', 'poe_type', 'mark_connected', 'rf_role', 'description', 'tags',
) )

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
@ -56,6 +56,7 @@ __all__ = (
class RegionImportForm(NetBoxModelImportForm): class RegionImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -69,6 +70,7 @@ class RegionImportForm(NetBoxModelImportForm):
class SiteGroupImportForm(NetBoxModelImportForm): class SiteGroupImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -82,22 +84,26 @@ class SiteGroupImportForm(NetBoxModelImportForm):
class SiteImportForm(NetBoxModelImportForm): class SiteImportForm(NetBoxModelImportForm):
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=SiteStatusChoices, choices=SiteStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
region = CSVModelChoiceField( region = CSVModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned region') help_text=_('Assigned region')
) )
group = CSVModelChoiceField( group = CSVModelChoiceField(
label=_('Group'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned group') help_text=_('Assigned group')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -119,11 +125,13 @@ class SiteImportForm(NetBoxModelImportForm):
class LocationImportForm(NetBoxModelImportForm): class LocationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned site') help_text=_('Assigned site')
) )
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -133,10 +141,12 @@ class LocationImportForm(NetBoxModelImportForm):
} }
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=LocationStatusChoices, choices=LocationStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -161,45 +171,54 @@ class RackRoleImportForm(NetBoxModelImportForm):
class RackImportForm(NetBoxModelImportForm): class RackImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name' to_field_name='name'
) )
location = CSVModelChoiceField( location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
to_field_name='name' to_field_name='name'
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Name of assigned tenant') help_text=_('Name of assigned tenant')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=RackStatusChoices, choices=RackStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
role = CSVModelChoiceField( role = CSVModelChoiceField(
label=_('Role'),
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Name of assigned role') help_text=_('Name of assigned role')
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=RackTypeChoices, choices=RackTypeChoices,
required=False, required=False,
help_text=_('Rack type') help_text=_('Rack type')
) )
width = forms.ChoiceField( width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices, choices=RackWidthChoices,
help_text=_('Rail-to-rail width (in inches)') help_text=_('Rail-to-rail width (in inches)')
) )
outer_unit = CSVChoiceField( outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices, choices=RackDimensionUnitChoices,
required=False, required=False,
help_text=_('Unit for outer dimensions') help_text=_('Unit for outer dimensions')
) )
weight_unit = CSVChoiceField( weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices, choices=WeightUnitChoices,
required=False, required=False,
help_text=_('Unit for rack weights') help_text=_('Unit for rack weights')
@ -225,27 +244,32 @@ class RackImportForm(NetBoxModelImportForm):
class RackReservationImportForm(NetBoxModelImportForm): class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Parent site') help_text=_('Parent site')
) )
location = CSVModelChoiceField( location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_("Rack's location (if any)") help_text=_("Rack's location (if any)")
) )
rack = CSVModelChoiceField( rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Rack') help_text=_('Rack')
) )
units = SimpleArrayField( units = SimpleArrayField(
label=_('Units'),
base_field=forms.IntegerField(), base_field=forms.IntegerField(),
required=True, required=True,
help_text=_('Comma-separated list of individual unit numbers') help_text=_('Comma-separated list of individual unit numbers')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -282,21 +306,25 @@ class ManufacturerImportForm(NetBoxModelImportForm):
class DeviceTypeImportForm(NetBoxModelImportForm): class DeviceTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('The manufacturer which produces this device type') help_text=_('The manufacturer which produces this device type')
) )
default_platform = forms.ModelChoiceField( default_platform = forms.ModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('The default platform for devices of this type (optional)') help_text=_('The default platform for devices of this type (optional)')
) )
weight = forms.DecimalField( weight = forms.DecimalField(
label=_('Weight'),
required=False, required=False,
help_text=_('Device weight'), help_text=_('Device weight'),
) )
weight_unit = CSVChoiceField( weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices, choices=WeightUnitChoices,
required=False, required=False,
help_text=_('Unit for device weight') help_text=_('Unit for device weight')
@ -312,14 +340,17 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
class ModuleTypeImportForm(NetBoxModelImportForm): class ModuleTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name' to_field_name='name'
) )
weight = forms.DecimalField( weight = forms.DecimalField(
label=_('Weight'),
required=False, required=False,
help_text=_('Module weight'), help_text=_('Module weight'),
) )
weight_unit = CSVChoiceField( weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices, choices=WeightUnitChoices,
required=False, required=False,
help_text=_('Unit for module weight') help_text=_('Unit for module weight')
@ -332,6 +363,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class DeviceRoleImportForm(NetBoxModelImportForm): class DeviceRoleImportForm(NetBoxModelImportForm):
config_template = CSVModelChoiceField( config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
@ -350,12 +382,14 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class PlatformImportForm(NetBoxModelImportForm): class PlatformImportForm(NetBoxModelImportForm):
slug = SlugField() slug = SlugField()
manufacturer = CSVModelChoiceField( manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Limit platform assignments to this manufacturer') help_text=_('Limit platform assignments to this manufacturer')
) )
config_template = CSVModelChoiceField( config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
@ -365,49 +399,57 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ( fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
) )
class BaseDeviceImportForm(NetBoxModelImportForm): class BaseDeviceImportForm(NetBoxModelImportForm):
device_role = CSVModelChoiceField( role = CSVModelChoiceField(
label=_('Device role'),
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned role') help_text=_('Assigned role')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
manufacturer = CSVModelChoiceField( manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Device type manufacturer') help_text=_('Device type manufacturer')
) )
device_type = CSVModelChoiceField( device_type = CSVModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
to_field_name='model', to_field_name='model',
help_text=_('Device type model') help_text=_('Device type model')
) )
platform = CSVModelChoiceField( platform = CSVModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned platform') help_text=_('Assigned platform')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
virtual_chassis = CSVModelChoiceField( virtual_chassis = CSVModelChoiceField(
label=_('Virtual chassis'),
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('Virtual chassis') help_text=_('Virtual chassis')
) )
cluster = CSVModelChoiceField( cluster = CSVModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
@ -430,45 +472,53 @@ class BaseDeviceImportForm(NetBoxModelImportForm):
class DeviceImportForm(BaseDeviceImportForm): class DeviceImportForm(BaseDeviceImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned site') help_text=_('Assigned site')
) )
location = CSVModelChoiceField( location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_("Assigned location (if any)") help_text=_("Assigned location (if any)")
) )
rack = CSVModelChoiceField( rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_("Assigned rack (if any)") help_text=_("Assigned rack (if any)")
) )
face = CSVChoiceField( face = CSVChoiceField(
label=_('Face'),
choices=DeviceFaceChoices, choices=DeviceFaceChoices,
required=False, required=False,
help_text=_('Mounted rack face') help_text=_('Mounted rack face')
) )
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('Parent device (for child devices)') help_text=_('Parent device (for child devices)')
) )
device_bay = CSVModelChoiceField( device_bay = CSVModelChoiceField(
label=_('Device bay'),
queryset=DeviceBay.objects.all(), queryset=DeviceBay.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('Device bay in which this device is installed (for child devices)') help_text=_('Device bay in which this device is installed (for child devices)')
) )
airflow = CSVChoiceField( airflow = CSVChoiceField(
label=_('Airflow'),
choices=DeviceAirflowChoices, choices=DeviceAirflowChoices,
required=False, required=False,
help_text=_('Airflow direction') help_text=_('Airflow direction')
) )
config_template = CSVModelChoiceField( config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
@ -477,9 +527,10 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta): class Meta(BaseDeviceImportForm.Meta):
fields = [ fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
'tags',
] ]
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -522,29 +573,35 @@ class DeviceImportForm(BaseDeviceImportForm):
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('The device in which this module is installed') help_text=_('The device in which this module is installed')
) )
module_bay = CSVModelChoiceField( module_bay = CSVModelChoiceField(
label=_('Module bay'),
queryset=ModuleBay.objects.all(), queryset=ModuleBay.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('The module bay in which this module is installed') help_text=_('The module bay in which this module is installed')
) )
module_type = CSVModelChoiceField( module_type = CSVModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
to_field_name='model', to_field_name='model',
help_text=_('The type of module') help_text=_('The type of module')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=ModuleStatusChoices, choices=ModuleStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
replicate_components = forms.BooleanField( replicate_components = forms.BooleanField(
label=_('Replicate components'),
required=False, required=False,
help_text=_('Automatically populate components associated with this module type (enabled by default)') help_text=_('Automatically populate components associated with this module type (enabled by default)')
) )
adopt_components = forms.BooleanField( adopt_components = forms.BooleanField(
label=_('Adopt components'),
required=False, required=False,
help_text=_('Adopt already existing components') help_text=_('Adopt already existing components')
) )
@ -578,15 +635,18 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
class ConsolePortImportForm(NetBoxModelImportForm): class ConsolePortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
required=False, required=False,
help_text=_('Port type') help_text=_('Port type')
) )
speed = CSVTypedChoiceField( speed = CSVTypedChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
coerce=int, coerce=int,
empty_value=None, empty_value=None,
@ -601,15 +661,18 @@ class ConsolePortImportForm(NetBoxModelImportForm):
class ConsoleServerPortImportForm(NetBoxModelImportForm): class ConsoleServerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
required=False, required=False,
help_text=_('Port type') help_text=_('Port type')
) )
speed = CSVTypedChoiceField( speed = CSVTypedChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
coerce=int, coerce=int,
empty_value=None, empty_value=None,
@ -624,10 +687,12 @@ class ConsoleServerPortImportForm(NetBoxModelImportForm):
class PowerPortImportForm(NetBoxModelImportForm): class PowerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
required=False, required=False,
help_text=_('Port type') help_text=_('Port type')
@ -642,21 +707,25 @@ class PowerPortImportForm(NetBoxModelImportForm):
class PowerOutletImportForm(NetBoxModelImportForm): class PowerOutletImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
required=False, required=False,
help_text=_('Outlet type') help_text=_('Outlet type')
) )
power_port = CSVModelChoiceField( power_port = CSVModelChoiceField(
label=_('Power port'),
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Local power port which feeds this outlet') help_text=_('Local power port which feeds this outlet')
) )
feed_leg = CSVChoiceField( feed_leg = CSVChoiceField(
label=_('Feed lag'),
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
required=False, required=False,
help_text=_('Electrical phase (for three-phase circuits)') help_text=_('Electrical phase (for three-phase circuits)')
@ -691,63 +760,75 @@ class PowerOutletImportForm(NetBoxModelImportForm):
class InterfaceImportForm(NetBoxModelImportForm): class InterfaceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Parent interface') help_text=_('Parent interface')
) )
bridge = CSVModelChoiceField( bridge = CSVModelChoiceField(
label=_('Bridge'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Bridged interface') help_text=_('Bridged interface')
) )
lag = CSVModelChoiceField( lag = CSVModelChoiceField(
label=_('Lag'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Parent LAG interface') help_text=_('Parent LAG interface')
) )
vdcs = CSVModelMultipleChoiceField( vdcs = CSVModelMultipleChoiceField(
label=_('Vdcs'),
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")' help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
help_text=_('Physical medium') help_text=_('Physical medium')
) )
duplex = CSVChoiceField( duplex = CSVChoiceField(
label=_('Duplex'),
choices=InterfaceDuplexChoices, choices=InterfaceDuplexChoices,
required=False required=False
) )
poe_mode = CSVChoiceField( poe_mode = CSVChoiceField(
label=_('Poe mode'),
choices=InterfacePoEModeChoices, choices=InterfacePoEModeChoices,
required=False, required=False,
help_text=_('PoE mode') help_text=_('PoE mode')
) )
poe_type = CSVChoiceField( poe_type = CSVChoiceField(
label=_('Poe type'),
choices=InterfacePoETypeChoices, choices=InterfacePoETypeChoices,
required=False, required=False,
help_text=_('PoE type') help_text=_('PoE type')
) )
mode = CSVChoiceField( mode = CSVChoiceField(
label=_('Mode'),
choices=InterfaceModeChoices, choices=InterfaceModeChoices,
required=False, required=False,
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
) )
vrf = CSVModelChoiceField( vrf = CSVModelChoiceField(
label=_('VRF'),
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
to_field_name='rd', to_field_name='rd',
help_text=_('Assigned VRF') help_text=_('Assigned VRF')
) )
rf_role = CSVChoiceField( rf_role = CSVChoiceField(
label=_('Rf role'),
choices=WirelessRoleChoices, choices=WirelessRoleChoices,
required=False, required=False,
help_text=_('Wireless role (AP/station)') help_text=_('Wireless role (AP/station)')
@ -791,15 +872,18 @@ class InterfaceImportForm(NetBoxModelImportForm):
class FrontPortImportForm(NetBoxModelImportForm): class FrontPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
rear_port = CSVModelChoiceField( rear_port = CSVModelChoiceField(
label=_('Rear port'),
queryset=RearPort.objects.all(), queryset=RearPort.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Corresponding rear port') help_text=_('Corresponding rear port')
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=PortTypeChoices, choices=PortTypeChoices,
help_text=_('Physical medium classification') help_text=_('Physical medium classification')
) )
@ -836,10 +920,12 @@ class FrontPortImportForm(NetBoxModelImportForm):
class RearPortImportForm(NetBoxModelImportForm): class RearPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
help_text=_('Physical medium classification'), help_text=_('Physical medium classification'),
choices=PortTypeChoices, choices=PortTypeChoices,
) )
@ -851,6 +937,7 @@ class RearPortImportForm(NetBoxModelImportForm):
class ModuleBayImportForm(NetBoxModelImportForm): class ModuleBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
@ -862,10 +949,12 @@ class ModuleBayImportForm(NetBoxModelImportForm):
class DeviceBayImportForm(NetBoxModelImportForm): class DeviceBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
installed_device = CSVModelChoiceField( installed_device = CSVModelChoiceField(
label=_('Installed device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
@ -908,32 +997,38 @@ class DeviceBayImportForm(NetBoxModelImportForm):
class InventoryItemImportForm(NetBoxModelImportForm): class InventoryItemImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
role = CSVModelChoiceField( role = CSVModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False
) )
manufacturer = CSVModelChoiceField( manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False
) )
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('Parent inventory item') help_text=_('Parent inventory item')
) )
component_type = CSVContentTypeField( component_type = CSVContentTypeField(
label=_('Component type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS, limit_choices_to=MODULAR_COMPONENT_MODELS,
required=False, required=False,
help_text=_('Component Type') help_text=_('Component Type')
) )
component_name = forms.CharField( component_name = forms.CharField(
label=_('Compnent name'),
required=False, required=False,
help_text=_('Component Name') help_text=_('Component Name')
) )
@ -1001,52 +1096,62 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm): class CableImportForm(NetBoxModelImportForm):
# Termination A # Termination A
side_a_device = CSVModelChoiceField( side_a_device = CSVModelChoiceField(
label=_('Side a device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Side A device') help_text=_('Side A device')
) )
side_a_type = CSVContentTypeField( side_a_type = CSVContentTypeField(
label=_('Side a type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side A type') help_text=_('Side A type')
) )
side_a_name = forms.CharField( side_a_name = forms.CharField(
label=_('Side a name'),
help_text=_('Side A component name') help_text=_('Side A component name')
) )
# Termination B # Termination B
side_b_device = CSVModelChoiceField( side_b_device = CSVModelChoiceField(
label=_('Side b device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Side B device') help_text=_('Side B device')
) )
side_b_type = CSVContentTypeField( side_b_type = CSVContentTypeField(
label=_('Side b type'),
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS, limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side B type') help_text=_('Side B type')
) )
side_b_name = forms.CharField( side_b_name = forms.CharField(
label=_('Side b name'),
help_text=_('Side B component name') help_text=_('Side B component name')
) )
# Cable attributes # Cable attributes
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=LinkStatusChoices, choices=LinkStatusChoices,
required=False, required=False,
help_text=_('Connection status') help_text=_('Connection status')
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=CableTypeChoices, choices=CableTypeChoices,
required=False, required=False,
help_text=_('Physical medium classification') help_text=_('Physical medium classification')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
length_unit = CSVChoiceField( length_unit = CSVChoiceField(
label=_('Length unit'),
choices=CableLengthUnitChoices, choices=CableLengthUnitChoices,
required=False, required=False,
help_text=_('Length unit') help_text=_('Length unit')
@ -1109,6 +1214,7 @@ class CableImportForm(NetBoxModelImportForm):
class VirtualChassisImportForm(NetBoxModelImportForm): class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField( master = CSVModelChoiceField(
label=_('Master'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
@ -1126,11 +1232,13 @@ class VirtualChassisImportForm(NetBoxModelImportForm):
class PowerPanelImportForm(NetBoxModelImportForm): class PowerPanelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Name of parent site') help_text=_('Name of parent site')
) )
location = CSVModelChoiceField( location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
to_field_name='name' to_field_name='name'
@ -1152,40 +1260,54 @@ class PowerPanelImportForm(NetBoxModelImportForm):
class PowerFeedImportForm(NetBoxModelImportForm): class PowerFeedImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Assigned site') help_text=_('Assigned site')
) )
power_panel = CSVModelChoiceField( power_panel = CSVModelChoiceField(
label=_('Power panel'),
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
to_field_name='name', to_field_name='name',
help_text=_('Upstream power panel') help_text=_('Upstream power panel')
) )
location = CSVModelChoiceField( location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_("Rack's location (if any)") help_text=_("Rack's location (if any)")
) )
rack = CSVModelChoiceField( rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
to_field_name='name', to_field_name='name',
required=False, required=False,
help_text=_('Rack') help_text=_('Rack')
) )
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned tenant')
)
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'),
choices=PowerFeedStatusChoices, choices=PowerFeedStatusChoices,
help_text=_('Operational status') help_text=_('Operational status')
) )
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'),
choices=PowerFeedTypeChoices, choices=PowerFeedTypeChoices,
help_text=_('Primary or redundant') help_text=_('Primary or redundant')
) )
supply = CSVChoiceField( supply = CSVChoiceField(
label=_('Supply'),
choices=PowerFeedSupplyChoices, choices=PowerFeedSupplyChoices,
help_text=_('Supply type (AC/DC)') help_text=_('Supply type (AC/DC)')
) )
phase = CSVChoiceField( phase = CSVChoiceField(
label=_('Phase'),
choices=PowerFeedPhaseChoices, choices=PowerFeedPhaseChoices,
help_text=_('Single or three-phase') help_text=_('Single or three-phase')
) )
@ -1194,7 +1316,7 @@ class PowerFeedImportForm(NetBoxModelImportForm):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags', 'voltage', 'amperage', 'max_utilization', 'tenant', 'description', 'comments', 'tags',
) )
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
@ -1221,11 +1343,13 @@ class PowerFeedImportForm(NetBoxModelImportForm):
class VirtualDeviceContextImportForm(NetBoxModelImportForm): class VirtualDeviceContextImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned role' help_text='Assigned role'
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
@ -47,7 +47,7 @@ class InterfaceCommonForm(forms.Form):
# Untagged interfaces cannot be assigned tagged VLANs # Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({ raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned." 'mode': _("An access interface cannot have tagged VLANs assigned.")
}) })
# Remove all tagged VLAN assignments from "tagged all" interfaces # Remove all tagged VLAN assignments from "tagged all" interfaces
@ -61,8 +61,10 @@ class InterfaceCommonForm(forms.Form):
if invalid_vlans: if invalid_vlans:
raise forms.ValidationError({ raise forms.ValidationError({
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as " 'tagged_vlans': _(
f"the interface's parent device/VM, or they must be global" "The tagged VLANs ({vlans}) must belong to the same site as the interface's parent device/VM, "
"or they must be global"
).format(vlans=', '.join(invalid_vlans))
}) })
@ -105,7 +107,7 @@ class ModuleCommonForm(forms.Form):
# Installing modules with placeholders require that the bay has a position value # Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position: if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError( raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined" _("Cannot install module with placeholder values in a module bay with no position defined.")
) )
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position) resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
@ -114,12 +116,17 @@ class ModuleCommonForm(forms.Form):
# It is not possible to adopt components already belonging to a module # It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module: if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError( raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs " _("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format(
f"to a module" name=template.component_model.__name__,
resolved_name=resolved_name
)
) )
# If we are not adopting components we error if the component exists # If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components: if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError( raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists" _("{name} - {resolved_name} already exists").format(
name=template.component_model.__name__,
resolved_name=resolved_name
)
) )

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
from dcim.models import * from dcim.models import *

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
@ -56,9 +56,11 @@ __all__ = (
class DeviceComponentFilterForm(NetBoxModelFilterSetForm): class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
name = forms.CharField( name = forms.CharField(
label=_('Name'),
required=False required=False
) )
label = forms.CharField( label = forms.CharField(
label=_('Label'),
required=False required=False
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@ -107,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Device type') label=_('Device type')
) )
device_role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
required=False, required=False,
label=_('Device role') label=_('Device role')
@ -120,7 +122,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
'location_id': '$location_id', 'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id', 'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id', 'device_type_id': '$device_type_id',
'role_id': '$device_role_id' 'role_id': '$role_id'
}, },
label=_('Device') label=_('Device')
) )
@ -130,7 +132,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag', 'parent_id')), (None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')) (_('Contacts'), ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -144,7 +146,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag', 'parent_id')), (None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')) (_('Contacts'), ('contact', 'contact_role', 'contact_group'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
@ -158,11 +160,12 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Site model = Site
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), (_('Attributes'), ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=SiteStatusChoices, choices=SiteStatusChoices,
required=False required=False
) )
@ -188,9 +191,9 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location model = Location
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), (_('Attributes'), ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -221,6 +224,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
label=_('Parent') label=_('Parent')
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=LocationStatusChoices, choices=LocationStatusChoices,
required=False required=False
) )
@ -236,12 +240,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Rack model = Rack
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')), (_('Function'), ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')), (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'max_weight', 'weight_unit')), (_('Weight'), ('weight', 'max_weight', 'weight_unit')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -271,14 +275,17 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
label=_('Location') label=_('Location')
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=RackStatusChoices, choices=RackStatusChoices,
required=False required=False
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=RackTypeChoices, choices=RackTypeChoices,
required=False required=False
) )
width = forms.MultipleChoiceField( width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices, choices=RackWidthChoices,
required=False required=False
) )
@ -289,21 +296,26 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
label=_('Role') label=_('Role')
) )
serial = forms.CharField( serial = forms.CharField(
label=_('Serial'),
required=False required=False
) )
asset_tag = forms.CharField( asset_tag = forms.CharField(
label=_('Asset tag'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField( weight = forms.DecimalField(
label=_('Weight'),
required=False, required=False,
min_value=1 min_value=1
) )
max_weight = forms.IntegerField( max_weight = forms.IntegerField(
label=_('Max weight'),
required=False, required=False,
min_value=1 min_value=1
) )
weight_unit = forms.ChoiceField( weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices), choices=add_blank_choice(WeightUnitChoices),
required=False required=False
) )
@ -312,12 +324,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
class RackElevationFilterForm(RackFilterForm): class RackElevationFilterForm(RackFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
('Function', ('status', 'role_id')), (_('Function'), ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')), (_('Hardware'), ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'max_weight', 'weight_unit')), (_('Weight'), ('weight', 'max_weight', 'weight_unit')),
) )
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
@ -334,9 +346,9 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('User', ('user_id',)), (_('User'), ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Rack'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -376,7 +388,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=get_user_model().objects.all(),
required=False, required=False,
label=_('User'), label=_('User'),
widget=APISelectMultiple( widget=APISelectMultiple(
@ -390,7 +402,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group')) (_('Contacts'), ('contact', 'contact_role', 'contact_group'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -399,13 +411,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')), (_('Hardware'), ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')),
('Images', ('has_front_image', 'has_rear_image')), (_('Images'), ('has_front_image', 'has_rear_image')),
('Components', ( (_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)), )),
('Weight', ('weight', 'weight_unit')), (_('Weight'), ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -418,98 +430,103 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
label=_('Default platform') label=_('Default platform')
) )
part_number = forms.CharField( part_number = forms.CharField(
label=_('Part number'),
required=False required=False
) )
subdevice_role = forms.MultipleChoiceField( subdevice_role = forms.MultipleChoiceField(
label=_('Subdevice role'),
choices=add_blank_choice(SubdeviceRoleChoices), choices=add_blank_choice(SubdeviceRoleChoices),
required=False required=False
) )
airflow = forms.MultipleChoiceField( airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices), choices=add_blank_choice(DeviceAirflowChoices),
required=False required=False
) )
has_front_image = forms.NullBooleanField( has_front_image = forms.NullBooleanField(
required=False, required=False,
label='Has a front image', label=_('Has a front image'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
has_rear_image = forms.NullBooleanField( has_rear_image = forms.NullBooleanField(
required=False, required=False,
label='Has a rear image', label=_('Has a rear image'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
console_ports = forms.NullBooleanField( console_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console ports', label=_('Has console ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
console_server_ports = forms.NullBooleanField( console_server_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console server ports', label=_('Has console server ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_ports = forms.NullBooleanField( power_ports = forms.NullBooleanField(
required=False, required=False,
label='Has power ports', label=_('Has power ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_outlets = forms.NullBooleanField( power_outlets = forms.NullBooleanField(
required=False, required=False,
label='Has power outlets', label=_('Has power outlets'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
interfaces = forms.NullBooleanField( interfaces = forms.NullBooleanField(
required=False, required=False,
label='Has interfaces', label=_('Has interfaces'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
pass_through_ports = forms.NullBooleanField( pass_through_ports = forms.NullBooleanField(
required=False, required=False,
label='Has pass-through ports', label=_('Has pass-through ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
device_bays = forms.NullBooleanField( device_bays = forms.NullBooleanField(
required=False, required=False,
label='Has device bays', label=_('Has device bays'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
module_bays = forms.NullBooleanField( module_bays = forms.NullBooleanField(
required=False, required=False,
label='Has module bays', label=_('Has module bays'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
inventory_items = forms.NullBooleanField( inventory_items = forms.NullBooleanField(
required=False, required=False,
label='Has inventory items', label=_('Has inventory items'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField( weight = forms.DecimalField(
label=_('Weight'),
required=False required=False
) )
weight_unit = forms.ChoiceField( weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices), choices=add_blank_choice(WeightUnitChoices),
required=False required=False
) )
@ -519,12 +536,12 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'part_number')), (_('Hardware'), ('manufacturer_id', 'part_number')),
('Components', ( (_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'pass_through_ports',
)), )),
('Weight', ('weight', 'weight_unit')), (_('Weight'), ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -533,55 +550,58 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
fetch_trigger='open' fetch_trigger='open'
) )
part_number = forms.CharField( part_number = forms.CharField(
label=_('Part number'),
required=False required=False
) )
console_ports = forms.NullBooleanField( console_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console ports', label=_('Has console ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
console_server_ports = forms.NullBooleanField( console_server_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console server ports', label=_('Has console server ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_ports = forms.NullBooleanField( power_ports = forms.NullBooleanField(
required=False, required=False,
label='Has power ports', label=_('Has power ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_outlets = forms.NullBooleanField( power_outlets = forms.NullBooleanField(
required=False, required=False,
label='Has power outlets', label=_('Has power outlets'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
interfaces = forms.NullBooleanField( interfaces = forms.NullBooleanField(
required=False, required=False,
label='Has interfaces', label=_('Has interfaces'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
pass_through_ports = forms.NullBooleanField( pass_through_ports = forms.NullBooleanField(
required=False, required=False,
label='Has pass-through ports', label=_('Has pass-through ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField( weight = forms.DecimalField(
label=_('Weight'),
required=False required=False
) )
weight_unit = forms.ChoiceField( weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices), choices=add_blank_choice(WeightUnitChoices),
required=False required=False
) )
@ -621,15 +641,17 @@ class DeviceFilterForm(
model = Device model = Device
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), (_('Operation'), ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), (_('Hardware'), ('manufacturer_id', 'device_type_id', 'platform_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
('Components', ( (_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)), )),
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data')) (_('Miscellaneous'), (
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
))
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -694,22 +716,26 @@ class DeviceFilterForm(
label=_('Platform') label=_('Platform')
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
required=False required=False
) )
airflow = forms.MultipleChoiceField( airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices), choices=add_blank_choice(DeviceAirflowChoices),
required=False required=False
) )
serial = forms.CharField( serial = forms.CharField(
label=_('Serial'),
required=False required=False
) )
asset_tag = forms.CharField( asset_tag = forms.CharField(
label=_('Asset tag'),
required=False required=False
) )
mac_address = forms.CharField( mac_address = forms.CharField(
required=False, required=False,
label='MAC address' label=_('MAC address')
) )
config_template_id = DynamicModelMultipleChoiceField( config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
@ -718,56 +744,63 @@ class DeviceFilterForm(
) )
has_primary_ip = forms.NullBooleanField( has_primary_ip = forms.NullBooleanField(
required=False, required=False,
label='Has a primary IP', label=_('Has a primary IP'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_oob_ip = forms.NullBooleanField(
required=False,
label='Has an OOB IP',
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
virtual_chassis_member = forms.NullBooleanField( virtual_chassis_member = forms.NullBooleanField(
required=False, required=False,
label='Virtual chassis member', label=_('Virtual chassis member'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
console_ports = forms.NullBooleanField( console_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console ports', label=_('Has console ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
console_server_ports = forms.NullBooleanField( console_server_ports = forms.NullBooleanField(
required=False, required=False,
label='Has console server ports', label=_('Has console server ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_ports = forms.NullBooleanField( power_ports = forms.NullBooleanField(
required=False, required=False,
label='Has power ports', label=_('Has power ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
power_outlets = forms.NullBooleanField( power_outlets = forms.NullBooleanField(
required=False, required=False,
label='Has power outlets', label=_('Has power outlets'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
interfaces = forms.NullBooleanField( interfaces = forms.NullBooleanField(
required=False, required=False,
label='Has interfaces', label=_('Has interfaces'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
pass_through_ports = forms.NullBooleanField( pass_through_ports = forms.NullBooleanField(
required=False, required=False,
label='Has pass-through ports', label=_('Has pass-through ports'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
@ -782,8 +815,8 @@ class VirtualDeviceContextFilterForm(
model = VirtualDeviceContext model = VirtualDeviceContext
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('device', 'status', 'has_primary_ip')), (_('Attributes'), ('device', 'status', 'has_primary_ip')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
) )
device = DynamicModelMultipleChoiceField( device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -792,12 +825,13 @@ class VirtualDeviceContextFilterForm(
fetch_trigger='open' fetch_trigger='open'
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
required=False, required=False,
choices=add_blank_choice(VirtualDeviceContextStatusChoices) choices=add_blank_choice(VirtualDeviceContextStatusChoices)
) )
has_primary_ip = forms.NullBooleanField( has_primary_ip = forms.NullBooleanField(
required=False, required=False,
label='Has a primary IP', label=_('Has a primary IP'),
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
@ -809,7 +843,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module model = Module
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')), (_('Hardware'), ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -827,13 +861,16 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
fetch_trigger='open' fetch_trigger='open'
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=ModuleStatusChoices, choices=ModuleStatusChoices,
required=False required=False
) )
serial = forms.CharField( serial = forms.CharField(
label=_('Serial'),
required=False required=False
) )
asset_tag = forms.CharField( asset_tag = forms.CharField(
label=_('Asset tag'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -843,8 +880,8 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VirtualChassis model = VirtualChassis
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -872,9 +909,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable model = Cable
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), (_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), (_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -920,20 +957,25 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Device') label=_('Device')
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=add_blank_choice(CableTypeChoices), choices=add_blank_choice(CableTypeChoices),
required=False required=False
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
required=False, required=False,
choices=add_blank_choice(LinkStatusChoices) choices=add_blank_choice(LinkStatusChoices)
) )
color = ColorField( color = ColorField(
label=_('Color'),
required=False required=False
) )
length = forms.IntegerField( length = forms.IntegerField(
label=_('Length'),
required=False required=False
) )
length_unit = forms.ChoiceField( length_unit = forms.ChoiceField(
label=_('Length unit'),
choices=add_blank_choice(CableLengthUnitChoices), choices=add_blank_choice(CableLengthUnitChoices),
required=False required=False
) )
@ -944,8 +986,8 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = PowerPanel model = PowerPanel
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -978,12 +1020,13 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class PowerFeedFilterForm(NetBoxModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = PowerFeed model = PowerFeed
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), (_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -1022,28 +1065,35 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
label=_('Status'),
choices=PowerFeedStatusChoices, choices=PowerFeedStatusChoices,
required=False required=False
) )
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(PowerFeedTypeChoices), choices=add_blank_choice(PowerFeedTypeChoices),
required=False required=False
) )
supply = forms.ChoiceField( supply = forms.ChoiceField(
label=_('Supply'),
choices=add_blank_choice(PowerFeedSupplyChoices), choices=add_blank_choice(PowerFeedSupplyChoices),
required=False required=False
) )
phase = forms.ChoiceField( phase = forms.ChoiceField(
label=_('Phase'),
choices=add_blank_choice(PowerFeedPhaseChoices), choices=add_blank_choice(PowerFeedPhaseChoices),
required=False required=False
) )
voltage = forms.IntegerField( voltage = forms.IntegerField(
label=_('Voltage'),
required=False required=False
) )
amperage = forms.IntegerField( amperage = forms.IntegerField(
label=_('Amperage'),
required=False required=False
) )
max_utilization = forms.IntegerField( max_utilization = forms.IntegerField(
label=_('Max utilization'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1055,12 +1105,14 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
class CabledFilterForm(forms.Form): class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField( cabled = forms.NullBooleanField(
label=_('Cabled'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
occupied = forms.NullBooleanField( occupied = forms.NullBooleanField(
label=_('Occupied'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
@ -1070,6 +1122,7 @@ class CabledFilterForm(forms.Form):
class PathEndpointFilterForm(CabledFilterForm): class PathEndpointFilterForm(CabledFilterForm):
connected = forms.NullBooleanField( connected = forms.NullBooleanField(
label=_('Connected'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
@ -1081,16 +1134,18 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort model = ConsolePort
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), (_('Attributes'), ('name', 'label', 'type', 'speed')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), (_('Connection'), ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
required=False required=False
) )
speed = forms.MultipleChoiceField( speed = forms.MultipleChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
required=False required=False
) )
@ -1101,16 +1156,18 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
model = ConsoleServerPort model = ConsoleServerPort
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')), (_('Attributes'), ('name', 'label', 'type', 'speed')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), (_('Connection'), ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
required=False required=False
) )
speed = forms.MultipleChoiceField( speed = forms.MultipleChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
required=False required=False
) )
@ -1121,12 +1178,13 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort model = PowerPort
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), (_('Attributes'), ('name', 'label', 'type')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), (_('Connection'), ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
required=False required=False
) )
@ -1137,12 +1195,13 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')), (_('Attributes'), ('name', 'label', 'type')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')), (_('Connection'), ('cabled', 'connected', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
required=False required=False
) )
@ -1153,13 +1212,13 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface model = Interface
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), (_('Attributes'), ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), (_('Addressing'), ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')), (_('PoE'), ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), (_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')), (_('Connection'), ('cabled', 'connected', 'occupied')),
) )
vdc_id = DynamicModelMultipleChoiceField( vdc_id = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
@ -1170,30 +1229,36 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label=_('Virtual Device Context') label=_('Virtual Device Context')
) )
kind = forms.MultipleChoiceField( kind = forms.MultipleChoiceField(
label=_('Kind'),
choices=InterfaceKindChoices, choices=InterfaceKindChoices,
required=False required=False
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
required=False required=False
) )
speed = forms.IntegerField( speed = forms.IntegerField(
label=_('Speed'),
required=False, required=False,
widget=NumberWithOptions( widget=NumberWithOptions(
options=InterfaceSpeedChoices options=InterfaceSpeedChoices
) )
) )
duplex = forms.MultipleChoiceField( duplex = forms.MultipleChoiceField(
label=_('Duplex'),
choices=InterfaceDuplexChoices, choices=InterfaceDuplexChoices,
required=False required=False
) )
enabled = forms.NullBooleanField( enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
mgmt_only = forms.NullBooleanField( mgmt_only = forms.NullBooleanField(
label=_('Mgmt only'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
@ -1201,50 +1266,50 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
) )
mac_address = forms.CharField( mac_address = forms.CharField(
required=False, required=False,
label='MAC address' label=_('MAC address')
) )
wwn = forms.CharField( wwn = forms.CharField(
required=False, required=False,
label='WWN' label=_('WWN')
) )
poe_mode = forms.MultipleChoiceField( poe_mode = forms.MultipleChoiceField(
choices=InterfacePoEModeChoices, choices=InterfacePoEModeChoices,
required=False, required=False,
label='PoE mode' label=_('PoE mode')
) )
poe_type = forms.MultipleChoiceField( poe_type = forms.MultipleChoiceField(
choices=InterfacePoETypeChoices, choices=InterfacePoETypeChoices,
required=False, required=False,
label='PoE type' label=_('PoE type')
) )
rf_role = forms.MultipleChoiceField( rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices, choices=WirelessRoleChoices,
required=False, required=False,
label='Wireless role' label=_('Wireless role')
) )
rf_channel = forms.MultipleChoiceField( rf_channel = forms.MultipleChoiceField(
choices=WirelessChannelChoices, choices=WirelessChannelChoices,
required=False, required=False,
label='Wireless channel' label=_('Wireless channel')
) )
rf_channel_frequency = forms.IntegerField( rf_channel_frequency = forms.IntegerField(
required=False, required=False,
label='Channel frequency (MHz)' label=_('Channel frequency (MHz)')
) )
rf_channel_width = forms.IntegerField( rf_channel_width = forms.IntegerField(
required=False, required=False,
label='Channel width (MHz)' label=_('Channel width (MHz)')
) )
tx_power = forms.IntegerField( tx_power = forms.IntegerField(
required=False, required=False,
label='Transmit power (dBm)', label=_('Transmit power (dBm)'),
min_value=0, min_value=0,
max_value=127 max_value=127
) )
vrf_id = DynamicModelMultipleChoiceField( vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
label='VRF' label=_('VRF')
) )
l2vpn_id = DynamicModelMultipleChoiceField( l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(), queryset=L2VPN.objects.all(),
@ -1257,17 +1322,19 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), (_('Attributes'), ('name', 'label', 'type', 'color')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), (_('Cable'), ('cabled', 'occupied')),
) )
model = FrontPort model = FrontPort
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices, choices=PortTypeChoices,
required=False required=False
) )
color = ColorField( color = ColorField(
label=_('Color'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1277,16 +1344,18 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort model = RearPort
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')), (_('Attributes'), ('name', 'label', 'type', 'color')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')), (_('Cable'), ('cabled', 'occupied')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices, choices=PortTypeChoices,
required=False required=False
) )
color = ColorField( color = ColorField(
label=_('Color'),
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1296,12 +1365,13 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')), (_('Attributes'), ('name', 'label', 'position')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
label=_('Position'),
required=False required=False
) )
@ -1310,9 +1380,9 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')), (_('Attributes'), ('name', 'label')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@ -1321,9 +1391,9 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id', 'tag')), (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), (_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), (_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
@ -1337,12 +1407,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
label=_('Manufacturer') label=_('Manufacturer')
) )
serial = forms.CharField( serial = forms.CharField(
label=_('Serial'),
required=False required=False
) )
asset_tag = forms.CharField( asset_tag = forms.CharField(
label=_('Asset tag'),
required=False required=False
) )
discovered = forms.NullBooleanField( discovered = forms.NullBooleanField(
label=_('Discovered'),
required=False, required=False,
widget=forms.Select( widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _
__all__ = ( __all__ = (
'BaseVCMemberFormSet', 'BaseVCMemberFormSet',
@ -16,6 +17,8 @@ class BaseVCMemberFormSet(forms.BaseModelFormSet):
vc_position = form.cleaned_data.get('vc_position') vc_position = form.cleaned_data.get('vc_position')
if vc_position: if vc_position:
if vc_position in vc_position_list: if vc_position in vc_position_list:
error_msg = f"A virtual chassis member already exists in position {vc_position}." error_msg = _("A virtual chassis member already exists in position {vc_position}.").format(
vc_position=vc_position
)
form.add_error('vc_position', error_msg) form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position) vc_position_list.append(vc_position)

View File

@ -1,7 +1,7 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
@ -70,13 +70,14 @@ __all__ = (
class RegionForm(NetBoxModelForm): class RegionForm(NetBoxModelForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False required=False
) )
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Region', ( (_('Region'), (
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'tags',
)), )),
) )
@ -90,13 +91,14 @@ class RegionForm(NetBoxModelForm):
class SiteGroupForm(NetBoxModelForm): class SiteGroupForm(NetBoxModelForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False required=False
) )
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Site Group', ( (_('Site Group'), (
'parent', 'name', 'slug', 'description', 'tags', 'parent', 'name', 'slug', 'description', 'tags',
)), )),
) )
@ -110,10 +112,12 @@ class SiteGroupForm(NetBoxModelForm):
class SiteForm(TenancyForm, NetBoxModelForm): class SiteForm(TenancyForm, NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False required=False
) )
group = DynamicModelChoiceField( group = DynamicModelChoiceField(
label=_('Group'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False required=False
) )
@ -124,17 +128,18 @@ class SiteForm(TenancyForm, NetBoxModelForm):
) )
slug = SlugField() slug = SlugField()
time_zone = TimeZoneFormField( time_zone = TimeZoneFormField(
label=_('Time zone'),
choices=add_blank_choice(TimeZoneFormField().choices), choices=add_blank_choice(TimeZoneFormField().choices),
required=False required=False
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Site', ( (_('Site'), (
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
)), )),
('Tenancy', ('tenant_group', 'tenant')), (_('Tenancy'), ('tenant_group', 'tenant')),
('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), (_('Contact Info'), ('physical_address', 'shipping_address', 'latitude', 'longitude')),
) )
class Meta: class Meta:
@ -159,10 +164,12 @@ class SiteForm(TenancyForm, NetBoxModelForm):
class LocationForm(TenancyForm, NetBoxModelForm): class LocationForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
selector=True selector=True
) )
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -172,8 +179,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Location', ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')), (_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')), (_('Tenancy'), ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
@ -187,7 +194,7 @@ class RackRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Rack Role', ( (_('Rack Role'), (
'name', 'slug', 'color', 'description', 'tags', 'name', 'slug', 'color', 'description', 'tags',
)), )),
) )
@ -201,10 +208,12 @@ class RackRoleForm(NetBoxModelForm):
class RackForm(TenancyForm, NetBoxModelForm): class RackForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
selector=True selector=True
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -212,6 +221,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'),
queryset=RackRole.objects.all(), queryset=RackRole.objects.all(),
required=False required=False
) )
@ -221,30 +231,33 @@ class RackForm(TenancyForm, NetBoxModelForm):
model = Rack model = Rack
fields = [ fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
] ]
class RackReservationForm(TenancyForm, NetBoxModelForm): class RackReservationForm(TenancyForm, NetBoxModelForm):
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
selector=True selector=True
) )
units = NumericArrayField( units = NumericArrayField(
label=_('Units'),
base_field=forms.IntegerField(), base_field=forms.IntegerField(),
help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
) )
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( label=_('User'),
queryset=get_user_model().objects.order_by(
'username' 'username'
) )
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Reservation', ('rack', 'units', 'user', 'description', 'tags')), (_('Reservation'), ('rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')), (_('Tenancy'), ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
@ -258,7 +271,7 @@ class ManufacturerForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Manufacturer', ( (_('Manufacturer'), (
'name', 'slug', 'description', 'tags', 'name', 'slug', 'description', 'tags',
)), )),
) )
@ -272,23 +285,26 @@ class ManufacturerForm(NetBoxModelForm):
class DeviceTypeForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all() queryset=Manufacturer.objects.all()
) )
default_platform = DynamicModelChoiceField( default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False
) )
slug = SlugField( slug = SlugField(
label=_('Slug'),
slug_source='model' slug_source='model'
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Device Type', ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')), (_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
('Chassis', ( (_('Chassis'), (
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
)), )),
('Images', ('front_image', 'rear_image')), (_('Images'), ('front_image', 'rear_image')),
) )
class Meta: class Meta:
@ -310,13 +326,14 @@ class DeviceTypeForm(NetBoxModelForm):
class ModuleTypeForm(NetBoxModelForm): class ModuleTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all() queryset=Manufacturer.objects.all()
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')), (_('Module Type'), ('manufacturer', 'model', 'part_number', 'description', 'tags')),
('Weight', ('weight', 'weight_unit')) (_('Weight'), ('weight', 'weight_unit'))
) )
class Meta: class Meta:
@ -328,13 +345,14 @@ class ModuleTypeForm(NetBoxModelForm):
class DeviceRoleForm(NetBoxModelForm): class DeviceRoleForm(NetBoxModelForm):
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
) )
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Device Role', ( (_('Device Role'), (
'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
)), )),
) )
@ -348,39 +366,39 @@ class DeviceRoleForm(NetBoxModelForm):
class PlatformForm(NetBoxModelForm): class PlatformForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
) )
slug = SlugField( slug = SlugField(
label=_('Slug'),
max_length=64 max_length=64
) )
fieldsets = ( fieldsets = (
('Platform', ( (_('Platform'), ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
) )
class Meta: class Meta:
model = Platform model = Platform
fields = [ fields = [
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
] ]
widgets = {
'napalm_args': forms.Textarea(),
}
class DeviceForm(TenancyForm, NetBoxModelForm): class DeviceForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
selector=True selector=True
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -391,6 +409,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
} }
) )
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -399,6 +418,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
} }
) )
position = forms.DecimalField( position = forms.DecimalField(
label=_('Position'),
required=False, required=False,
help_text=_("The lowest-numbered unit occupied by the device"), help_text=_("The lowest-numbered unit occupied by the device"),
localize=True, localize=True,
@ -411,17 +431,21 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
) )
) )
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
selector=True selector=True
) )
device_role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Device role'),
queryset=DeviceRole.objects.all() queryset=DeviceRole.objects.all()
) )
platform = DynamicModelChoiceField( platform = DynamicModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False required=False
) )
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False, required=False,
selector=True selector=True
@ -432,6 +456,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label='' label=''
) )
virtual_chassis = DynamicModelChoiceField( virtual_chassis = DynamicModelChoiceField(
label=_('Virtual chassis'),
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
required=False, required=False,
selector=True selector=True
@ -447,6 +472,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
help_text=_("The priority of the device in the virtual chassis") help_text=_("The priority of the device in the virtual chassis")
) )
config_template = DynamicModelChoiceField( config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
) )
@ -454,10 +480,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', 'name', 'role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'local_context_data' 'comments', 'tags', 'local_context_data',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -466,6 +492,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if self.instance.pk: if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses # Compile list of choices for primary IPv4 and IPv6 addresses
oob_ip_choices = [(None, '---------')]
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
@ -481,6 +508,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if interface_ips: if interface_ips:
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips] ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list)) ip_choices.append(('Interface IPs', ip_list))
oob_ip_choices.extend(ip_list)
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, address__family=family,
@ -491,6 +519,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips] ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list)) ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices self.fields['primary_ip{}'.format(family)].choices = ip_choices
self.fields['oob_ip'].choices = oob_ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another. # can be flipped from one face to another.
@ -510,6 +539,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip4'].widget.attrs['readonly'] = True self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
self.fields['oob_ip'].choices = []
self.fields['oob_ip'].widget.attrs['readonly'] = True
# Rack position # Rack position
position = self.data.get('position') or self.initial.get('position') position = self.data.get('position') or self.initial.get('position')
@ -519,36 +550,41 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class ModuleForm(ModuleCommonForm, NetBoxModelForm): class ModuleForm(ModuleCommonForm, NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
initial_params={ initial_params={
'modulebays': '$module_bay' 'modulebays': '$module_bay'
} }
) )
module_bay = DynamicModelChoiceField( module_bay = DynamicModelChoiceField(
label=_('Module bay'),
queryset=ModuleBay.objects.all(), queryset=ModuleBay.objects.all(),
query_params={ query_params={
'device_id': '$device' 'device_id': '$device'
} }
) )
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
selector=True selector=True
) )
comments = CommentField() comments = CommentField()
replicate_components = forms.BooleanField( replicate_components = forms.BooleanField(
label=_('Replicate components'),
required=False, required=False,
initial=True, initial=True,
help_text=_("Automatically populate components associated with this module type") help_text=_("Automatically populate components associated with this module type")
) )
adopt_components = forms.BooleanField( adopt_components = forms.BooleanField(
label=_('Adopt components'),
required=False, required=False,
initial=False, initial=False,
help_text=_("Adopt already existing components") help_text=_("Adopt already existing components")
) )
fieldsets = ( fieldsets = (
('Module', ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')), (_('Module'), ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')),
('Hardware', ( (_('Hardware'), (
'serial', 'asset_tag', 'replicate_components', 'adopt_components', 'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)), )),
) )
@ -582,17 +618,19 @@ class CableForm(TenancyForm, NetBoxModelForm):
] ]
error_messages = { error_messages = {
'length': { 'length': {
'max_value': 'Maximum length is 32767 (any unit)' 'max_value': _('Maximum length is 32767 (any unit)')
} }
} }
class PowerPanelForm(NetBoxModelForm): class PowerPanelForm(NetBoxModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
selector=True selector=True
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(), queryset=Location.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -612,12 +650,14 @@ class PowerPanelForm(NetBoxModelForm):
] ]
class PowerFeedForm(NetBoxModelForm): class PowerFeedForm(TenancyForm, NetBoxModelForm):
power_panel = DynamicModelChoiceField( power_panel = DynamicModelChoiceField(
label=_('Power panel'),
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
selector=True selector=True
) )
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
selector=True selector=True
@ -625,15 +665,16 @@ class PowerFeedForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')), (_('Power Feed'), ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), (_('Characteristics'), ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
(_('Tenancy'), ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
model = PowerFeed model = PowerFeed
fields = [ fields = [
'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'description', 'comments', 'tags', 'max_utilization', 'tenant_group', 'tenant', 'description', 'comments', 'tags'
] ]
@ -643,6 +684,7 @@ class PowerFeedForm(NetBoxModelForm):
class VirtualChassisForm(NetBoxModelForm): class VirtualChassisForm(NetBoxModelForm):
master = forms.ModelChoiceField( master = forms.ModelChoiceField(
label=_('Master'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
) )
@ -706,6 +748,7 @@ class DeviceVCMembershipForm(forms.ModelForm):
class VCMemberSelectForm(BootstrapMixin, forms.Form): class VCMemberSelectForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
query_params={ query_params={
'virtual_chassis_id': 'null', 'virtual_chassis_id': 'null',
@ -728,6 +771,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
class ComponentTemplateForm(BootstrapMixin, forms.ModelForm): class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all() queryset=DeviceType.objects.all()
) )
@ -741,10 +785,12 @@ class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm): class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all().all(), queryset=DeviceType.objects.all().all(),
required=False required=False
) )
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
required=False required=False
) )
@ -797,6 +843,7 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
class PowerOutletTemplateForm(ModularComponentTemplateForm): class PowerOutletTemplateForm(ModularComponentTemplateForm):
power_port = DynamicModelChoiceField( power_port = DynamicModelChoiceField(
label=_('Power port'),
queryset=PowerPortTemplate.objects.all(), queryset=PowerPortTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -817,6 +864,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
class InterfaceTemplateForm(ModularComponentTemplateForm): class InterfaceTemplateForm(ModularComponentTemplateForm):
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
label=_('Bridge'),
queryset=InterfaceTemplate.objects.all(), queryset=InterfaceTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -827,18 +875,20 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')), (None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')),
('PoE', ('poe_mode', 'poe_type')) (_('PoE'), ('poe_mode', 'poe_type')),
(_('Wireless'), ('rf_role',)),
) )
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge', 'rf_role',
] ]
class FrontPortTemplateForm(ModularComponentTemplateForm): class FrontPortTemplateForm(ModularComponentTemplateForm):
rear_port = DynamicModelChoiceField( rear_port = DynamicModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(), queryset=RearPortTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -900,6 +950,7 @@ class DeviceBayTemplateForm(ComponentTemplateForm):
class InventoryItemTemplateForm(ComponentTemplateForm): class InventoryItemTemplateForm(ComponentTemplateForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=InventoryItemTemplate.objects.all(), queryset=InventoryItemTemplate.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -907,10 +958,12 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
required=False required=False
) )
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
@ -946,6 +999,7 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
class DeviceComponentForm(NetBoxModelForm): class DeviceComponentForm(NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
selector=True selector=True
) )
@ -960,6 +1014,7 @@ class DeviceComponentForm(NetBoxModelForm):
class ModularDeviceComponentForm(DeviceComponentForm): class ModularDeviceComponentForm(DeviceComponentForm):
module = DynamicModelChoiceField( module = DynamicModelChoiceField(
label=_('Module'),
queryset=Module.objects.all(), queryset=Module.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -1016,6 +1071,7 @@ class PowerPortForm(ModularDeviceComponentForm):
class PowerOutletForm(ModularDeviceComponentForm): class PowerOutletForm(ModularDeviceComponentForm):
power_port = DynamicModelChoiceField( power_port = DynamicModelChoiceField(
label=_('Power port'),
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -1042,7 +1098,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
vdcs = DynamicModelMultipleChoiceField( vdcs = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
required=False, required=False,
label='Virtual Device Contexts', label=_('Virtual device contexts'),
initial_params={ initial_params={
'interfaces': '$parent', 'interfaces': '$parent',
}, },
@ -1055,7 +1111,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False, required=False,
label=_('Parent interface'), label=_('Parent interface'),
query_params={ query_params={
'device_id': '$device', 'virtual_chassis_member_id': '$device',
} }
) )
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
@ -1063,7 +1119,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False, required=False,
label=_('Bridged interface'), label=_('Bridged interface'),
query_params={ query_params={
'device_id': '$device', 'virtual_chassis_member_id': '$device',
} }
) )
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
@ -1071,7 +1127,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False, required=False,
label=_('LAG interface'), label=_('LAG interface'),
query_params={ query_params={
'device_id': '$device', 'virtual_chassis_member_id': '$device',
'type': 'lag', 'type': 'lag',
} }
) )
@ -1123,13 +1179,13 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
) )
fieldsets = ( fieldsets = (
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), (_('Interface'), ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')), (_('Addressing'), ('vrf', 'mac_address', 'wwn')),
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), (_('Operation'), ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')), (_('Related Interfaces'), ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')), (_('PoE'), ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), (_('802.1Q Switching'), ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ( (_('Wireless'), (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
)), )),
) )
@ -1235,6 +1291,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class InventoryItemForm(DeviceComponentForm): class InventoryItemForm(DeviceComponentForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=InventoryItem.objects.all(), queryset=InventoryItem.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -1242,10 +1299,12 @@ class InventoryItemForm(DeviceComponentForm):
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
required=False required=False
) )
manufacturer = DynamicModelChoiceField( manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
@ -1309,8 +1368,8 @@ class InventoryItemForm(DeviceComponentForm):
) )
fieldsets = ( fieldsets = (
('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), (_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), (_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')),
) )
class Meta: class Meta:
@ -1361,7 +1420,7 @@ class InventoryItemForm(DeviceComponentForm):
) if self.cleaned_data[field] ) if self.cleaned_data[field]
] ]
if len(selected_objects) > 1: if len(selected_objects) > 1:
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.") raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component."))
elif selected_objects: elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]] self.instance.component = self.cleaned_data[selected_objects[0]]
else: else:
@ -1375,7 +1434,7 @@ class InventoryItemRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Inventory Item Role', ( (_('Inventory Item Role'), (
'name', 'slug', 'color', 'description', 'tags', 'name', 'slug', 'color', 'description', 'tags',
)), )),
) )
@ -1389,12 +1448,13 @@ class InventoryItemRoleForm(NetBoxModelForm):
class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm): class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
selector=True selector=True
) )
primary_ip4 = DynamicModelChoiceField( primary_ip4 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label='Primary IPv4', label=_('Primary IPv4'),
required=False, required=False,
query_params={ query_params={
'device_id': '$device', 'device_id': '$device',
@ -1403,7 +1463,7 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
) )
primary_ip6 = DynamicModelChoiceField( primary_ip6 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
label='Primary IPv6', label=_('Primary IPv6'),
required=False, required=False,
query_params={ query_params={
'device_id': '$device', 'device_id': '$device',
@ -1412,8 +1472,8 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('Virtual Device Context', ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')), (_('Virtual Device Context'), ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
('Tenancy', ('tenant_group', 'tenant')) (_('Tenancy'), ('tenant_group', 'tenant'))
) )
class Meta: class Meta:

View File

@ -1,5 +1,5 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
@ -38,8 +38,11 @@ class ComponentCreateForm(forms.Form):
Subclass this form when facilitating the creation of one or more component or component template objects based on Subclass this form when facilitating the creation of one or more component or component template objects based on
a name pattern. a name pattern.
""" """
name = ExpandableNameField() name = ExpandableNameField(
label=_('Name'),
)
label = ExpandableNameField( label = ExpandableNameField(
label=_('Label'),
required=False, required=False,
help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)') help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)')
) )
@ -60,8 +63,9 @@ class ComponentCreateForm(forms.Form):
value_count = len(self.cleaned_data[field_name]) value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count: if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({ raise forms.ValidationError({
field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are ' field_name: _(
f'expected.' "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch') }, code='label_pattern_mismatch')
@ -225,12 +229,14 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'module' in self.fields: if 'module' in self.fields:
self.fields['name'].help_text += ' The string <code>{module}</code> will be replaced with the position ' \ self.fields['name'].help_text += _(
'of the assigned module, if any' "The string <code>{module}</code> will be replaced with the position of the assigned module, if any."
)
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
selector=True, selector=True,
widget=APISelect( widget=APISelect(
@ -332,6 +338,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
class VirtualChassisCreateForm(NetBoxModelForm): class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
required=False, required=False,
initial_params={ initial_params={
@ -339,6 +346,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
} }
) )
site_group = DynamicModelChoiceField( site_group = DynamicModelChoiceField(
label=_('Site group'),
queryset=SiteGroup.objects.all(), queryset=SiteGroup.objects.all(),
required=False, required=False,
initial_params={ initial_params={
@ -346,6 +354,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
} }
) )
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -354,6 +363,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
} }
) )
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
null_option='None', null_option='None',
@ -362,6 +372,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
} }
) )
members = DynamicModelMultipleChoiceField( members = DynamicModelMultipleChoiceField(
label=_('Members'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
query_params={ query_params={
@ -370,6 +381,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
} }
) )
initial_position = forms.IntegerField( initial_position = forms.IntegerField(
label=_('Initial position'),
initial=1, initial=1,
required=False, required=False,
help_text=_('Position of the first member device. Increases by one for each additional member.') help_text=_('Position of the first member device. Increases by one for each additional member.')
@ -386,7 +398,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({ raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member." 'initial_position': _("A position must be specified for the first VC member.")
}) })
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View File

@ -1,9 +1,10 @@
from django import forms from django import forms
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import * from dcim.models import *
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from wireless.choices import WirelessRoleChoices
__all__ = ( __all__ = (
'ConsolePortTemplateImportForm', 'ConsolePortTemplateImportForm',
@ -56,6 +57,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
power_port = forms.ModelChoiceField( power_port = forms.ModelChoiceField(
label=_('Power port'),
queryset=PowerPortTemplate.objects.all(), queryset=PowerPortTemplate.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False
@ -84,6 +86,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class InterfaceTemplateImportForm(ComponentTemplateImportForm): class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices.CHOICES choices=InterfaceTypeChoices.CHOICES
) )
poe_mode = forms.ChoiceField( poe_mode = forms.ChoiceField(
@ -96,19 +99,27 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
required=False, required=False,
label=_('PoE type') label=_('PoE type')
) )
rf_role = forms.ChoiceField(
choices=WirelessRoleChoices,
required=False,
label=_('Wireless role')
)
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
'poe_type', 'rf_role'
] ]
class FrontPortTemplateImportForm(ComponentTemplateImportForm): class FrontPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'),
choices=PortTypeChoices.CHOICES choices=PortTypeChoices.CHOICES
) )
rear_port = forms.ModelChoiceField( rear_port = forms.ModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(), queryset=RearPortTemplate.objects.all(),
to_field_name='name' to_field_name='name'
) )
@ -136,6 +147,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class RearPortTemplateImportForm(ComponentTemplateImportForm): class RearPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField( type = forms.ChoiceField(
label=_('Type'),
choices=PortTypeChoices.CHOICES choices=PortTypeChoices.CHOICES
) )
@ -166,15 +178,18 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class InventoryItemTemplateImportForm(ComponentTemplateImportForm): class InventoryItemTemplateImportForm(ComponentTemplateImportForm):
parent = forms.ModelChoiceField( parent = forms.ModelChoiceField(
label=_('Parent'),
queryset=InventoryItemTemplate.objects.all(), queryset=InventoryItemTemplate.objects.all(),
required=False required=False
) )
role = forms.ModelChoiceField( role = forms.ModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False
) )
manufacturer = forms.ModelChoiceField( manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
to_field_name='name', to_field_name='name',
required=False required=False

View File

@ -277,6 +277,9 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
def resolve_poe_type(self, info): def resolve_poe_type(self, info):
return self.poe_type or None return self.poe_type or None
def resolve_rf_role(self, info):
return self.rf_role or None
class InventoryItemType(ComponentObjectType): class InventoryItemType(ComponentObjectType):
component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType') component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')

View File

@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='device', model_name='device',
name='config_template', name='config_template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'), field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
), ),
migrations.AddField( migrations.AddField(
model_name='devicerole', model_name='devicerole',

View File

@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0172_larger_power_draw_values'),
]
operations = [
migrations.RemoveField(
model_name='platform',
name='napalm_args',
),
migrations.RemoveField(
model_name='platform',
name='napalm_driver',
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.1.9 on 2023-05-31 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0173_remove_napalm_fields'),
]
operations = [
migrations.AddField(
model_name='device',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='device',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0174_device_latitude_device_longitude'),
]
operations = [
migrations.AddField(
model_name='rack',
name='starting_unit',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.9 on 2023-07-24 20:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0066_iprange_mark_utilized'),
('dcim', '0174_rack_starting_unit'),
]
operations = [
migrations.AddField(
model_name='device',
name='oob_ip',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='ipam.ipaddress',
),
),
]

View File

@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
devices = Device.objects.annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
_power_outlet_count=Count('poweroutlets', distinct=True),
_interface_count=Count('interfaces', distinct=True),
_front_port_count=Count('frontports', distinct=True),
_rear_port_count=Count('rearports', distinct=True),
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
)
for device in devices:
device.console_port_count = device._console_port_count
device.console_server_port_count = device._console_server_port_count
device.power_port_count = device._power_port_count
device.power_outlet_count = device._power_outlet_count
device.interface_count = device._interface_count
device.front_port_count = device._front_port_count
device.rear_port_count = device._rear_port_count
device.device_bay_count = device._device_bay_count
device.module_bay_count = device._module_bay_count
device.inventory_item_count = device._inventory_item_count
Device.objects.bulk_update(devices, [
'console_port_count',
'console_server_port_count',
'power_port_count',
'power_outlet_count',
'interface_count',
'front_port_count',
'rear_port_count',
'device_bay_count',
'module_bay_count',
'inventory_item_count',
], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0175_device_oob_ip'),
]
operations = [
migrations.AddField(
model_name='device',
name='console_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'),
),
migrations.AddField(
model_name='device',
name='console_server_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'),
),
migrations.AddField(
model_name='device',
name='power_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'),
),
migrations.AddField(
model_name='device',
name='power_outlet_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'),
),
migrations.AddField(
model_name='device',
name='interface_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'),
),
migrations.AddField(
model_name='device',
name='front_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'),
),
migrations.AddField(
model_name='device',
name='rear_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'),
),
migrations.AddField(
model_name='device',
name='device_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'),
),
migrations.AddField(
model_name='device',
name='module_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'),
),
migrations.AddField(
model_name='device',
name='inventory_item_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'),
),
migrations.RunPython(
recalculate_device_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
device_types = list(DeviceType.objects.all().annotate(
_console_port_template_count=Count('consoleporttemplates', distinct=True),
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
_power_port_template_count=Count('powerporttemplates', distinct=True),
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
_interface_template_count=Count('interfacetemplates', distinct=True),
_front_port_template_count=Count('frontporttemplates', distinct=True),
_rear_port_template_count=Count('rearporttemplates', distinct=True),
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
))
for devicetype in device_types:
devicetype.console_port_template_count = devicetype._console_port_template_count
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
devicetype.power_port_template_count = devicetype._power_port_template_count
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
devicetype.interface_template_count = devicetype._interface_template_count
devicetype.front_port_template_count = devicetype._front_port_template_count
devicetype.rear_port_template_count = devicetype._rear_port_template_count
devicetype.device_bay_template_count = devicetype._device_bay_template_count
devicetype.module_bay_template_count = devicetype._module_bay_template_count
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
DeviceType.objects.bulk_update(device_types, [
'console_port_template_count',
'console_server_port_template_count',
'power_port_template_count',
'power_outlet_template_count',
'interface_template_count',
'front_port_template_count',
'rear_port_template_count',
'device_bay_template_count',
'module_bay_template_count',
'inventory_item_template_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0176_device_component_counters'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='console_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='console_server_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_outlet_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='interface_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='front_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='rear_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='device_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='module_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='inventory_item_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'),
),
migrations.RunPython(
recalculate_devicetype_template_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0177_devicetype_component_counters'),
]
operations = [
migrations.AddField(
model_name='virtualchassis',
name='member_count',
field=utilities.fields.CounterCacheField(
default=0, to_field='virtual_chassis', to_model='dcim.Device'
),
),
migrations.RunPython(
code=populate_virtualchassis_members,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2023-07-18 07:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0178_virtual_chassis_member_counter'),
]
operations = [
migrations.AddField(
model_name='interfacetemplate',
name='rf_role',
field=models.CharField(blank=True, max_length=30),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.8 on 2023-07-29 11:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0010_tenant_relax_uniqueness'),
('dcim', '0179_interfacetemplate_rf_role'),
]
operations = [
migrations.AddField(
model_name='powerfeed',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'),
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
def update_table_configs(apps, schema_editor):
"""
Replace the `device_role` column in DeviceTable configs with `role`.
"""
UserConfig = apps.get_model('users', 'UserConfig')
for table in ('DeviceTable', 'DeviceBayTable'):
for config in UserConfig.objects.filter(**{f'data__tables__{table}__columns__contains': 'device_role'}):
config.data['tables'][table]['columns'] = [
'role' if x == 'device_role' else x
for x in config.data['tables'][table]['columns']
]
config.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0180_powerfeed_tenant'),
]
operations = [
migrations.RenameField(
model_name='device',
old_name='device_role',
new_name='role',
),
migrations.RunPython(
code=update_table_configs,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -8,6 +8,7 @@ from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.dispatch import Signal from django.dispatch import Signal
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
@ -40,11 +41,13 @@ class Cable(PrimaryModel):
A physical connection between two endpoints. A physical connection between two endpoints.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=CableTypeChoices, choices=CableTypeChoices,
blank=True blank=True
) )
status = models.CharField( status = models.CharField(
verbose_name=_('status'),
max_length=50, max_length=50,
choices=LinkStatusChoices, choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED default=LinkStatusChoices.STATUS_CONNECTED
@ -57,19 +60,23 @@ class Cable(PrimaryModel):
null=True null=True
) )
label = models.CharField( label = models.CharField(
verbose_name=_('label'),
max_length=100, max_length=100,
blank=True blank=True
) )
color = ColorField( color = ColorField(
verbose_name=_('color'),
blank=True blank=True
) )
length = models.DecimalField( length = models.DecimalField(
verbose_name=_('length'),
max_digits=8, max_digits=8,
decimal_places=2, decimal_places=2,
blank=True, blank=True,
null=True null=True
) )
length_unit = models.CharField( length_unit = models.CharField(
verbose_name=_('length unit'),
max_length=50, max_length=50,
choices=CableLengthUnitChoices, choices=CableLengthUnitChoices,
blank=True, blank=True,
@ -84,6 +91,8 @@ class Cable(PrimaryModel):
class Meta: class Meta:
ordering = ('pk',) ordering = ('pk',)
verbose_name = _('cable')
verbose_name_plural = _('cables')
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs): def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -235,7 +244,7 @@ class CableTermination(ChangeLoggedModel):
cable_end = models.CharField( cable_end = models.CharField(
max_length=1, max_length=1,
choices=CableEndChoices, choices=CableEndChoices,
verbose_name='End' verbose_name=_('end')
) )
termination_type = models.ForeignKey( termination_type = models.ForeignKey(
to=ContentType, to=ContentType,
@ -285,6 +294,8 @@ class CableTermination(ChangeLoggedModel):
name='%(app_label)s_%(class)s_unique_termination' name='%(app_label)s_%(class)s_unique_termination'
), ),
) )
verbose_name = _('cable termination')
verbose_name_plural = _('cable terminations')
def __str__(self): def __str__(self):
return f'Cable {self.cable} to {self.termination}' return f'Cable {self.cable} to {self.termination}'
@ -359,6 +370,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations # Circuit terminations
elif getattr(self.termination, 'site', None): elif getattr(self.termination, 'site', None):
self._site = self.termination.site self._site = self.termination.site
cache_related_objects.alters_data = True
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
@ -402,19 +414,27 @@ class CablePath(models.Model):
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering. `_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
""" """
path = models.JSONField( path = models.JSONField(
verbose_name=_('path'),
default=list default=list
) )
is_active = models.BooleanField( is_active = models.BooleanField(
verbose_name=_('is active'),
default=False default=False
) )
is_complete = models.BooleanField( is_complete = models.BooleanField(
verbose_name=_('is complete'),
default=False default=False
) )
is_split = models.BooleanField( is_split = models.BooleanField(
verbose_name=_('is split'),
default=False default=False
) )
_nodes = PathField() _nodes = PathField()
class Meta:
verbose_name = _('cable path')
verbose_name_plural = _('cable paths')
def __str__(self): def __str__(self):
return f"Path #{self.pk}: {len(self.path)} hops" return f"Path #{self.pk}: {len(self.path)} hops"
@ -637,6 +657,7 @@ class CablePath(models.Model):
self.save() self.save()
else: else:
self.delete() self.delete()
retrace.alters_data = True
def _get_path(self): def _get_path(self):
""" """

View File

@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
@ -12,6 +12,8 @@ from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.tracking import TrackingModelMixin
from wireless.choices import WirelessRoleChoices
from .device_components import ( from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort, RearPort,
@ -32,17 +34,18 @@ __all__ = (
) )
class ComponentTemplateModel(ChangeLoggedModel): class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
device_type = models.ForeignKey( device_type = models.ForeignKey(
to='dcim.DeviceType', to='dcim.DeviceType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='%(class)ss' related_name='%(class)ss'
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=64, max_length=64,
help_text=""" help_text=_(
{module} is accepted as a substitution for the module bay position when attached to a module type. "{module} is accepted as a substitution for the module bay position when attached to a module type."
""" )
) )
_name = NaturalOrderingField( _name = NaturalOrderingField(
target_field='name', target_field='name',
@ -50,11 +53,13 @@ class ComponentTemplateModel(ChangeLoggedModel):
blank=True blank=True
) )
label = models.CharField( label = models.CharField(
verbose_name=_('label'),
max_length=64, max_length=64,
blank=True, blank=True,
help_text=_("Physical label") help_text=_('Physical label')
) )
description = models.CharField( description = models.CharField(
verbose_name=_('description'),
max_length=200, max_length=200,
blank=True blank=True
) )
@ -96,7 +101,7 @@ class ComponentTemplateModel(ChangeLoggedModel):
if self.pk is not None and self._original_device_type != self.device_type_id: if self.pk is not None and self._original_device_type != self.device_type_id:
raise ValidationError({ raise ValidationError({
"device_type": "Component templates cannot be moved to a different device type." "device_type": _("Component templates cannot be moved to a different device type.")
}) })
@ -147,11 +152,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
# A component template must belong to a DeviceType *or* to a ModuleType # A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type: if self.device_type and self.module_type:
raise ValidationError( raise ValidationError(
"A component template cannot be associated with both a device type and a module type." _("A component template cannot be associated with both a device type and a module type.")
) )
if not self.device_type and not self.module_type: if not self.device_type and not self.module_type:
raise ValidationError( raise ValidationError(
"A component template must be associated with either a device type or a module type." _("A component template must be associated with either a device type or a module type.")
) )
def resolve_name(self, module): def resolve_name(self, module):
@ -170,6 +175,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
A template for a ConsolePort to be created for a new Device. A template for a ConsolePort to be created for a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
blank=True blank=True
@ -177,6 +183,10 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
component_model = ConsolePort component_model = ConsolePort
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('console port template')
verbose_name_plural = _('console port templates')
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -199,6 +209,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
A template for a ConsoleServerPort to be created for a new Device. A template for a ConsoleServerPort to be created for a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
blank=True blank=True
@ -206,6 +217,10 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
component_model = ConsoleServerPort component_model = ConsoleServerPort
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('console server port template')
verbose_name_plural = _('console server port templates')
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -213,6 +228,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
type=self.type, type=self.type,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -228,25 +244,32 @@ class PowerPortTemplate(ModularComponentTemplateModel):
A template for a PowerPort to be created for a new Device. A template for a PowerPort to be created for a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
blank=True blank=True
) )
maximum_draw = models.PositiveIntegerField( maximum_draw = models.PositiveIntegerField(
verbose_name=_('maximum draw'),
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_('Maximum power draw (watts)')
) )
allocated_draw = models.PositiveIntegerField( allocated_draw = models.PositiveIntegerField(
verbose_name=_('allocated draw'),
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Allocated power draw (watts)") help_text=_('Allocated power draw (watts)')
) )
component_model = PowerPort component_model = PowerPort
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('power port template')
verbose_name_plural = _('power port templates')
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -256,6 +279,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
allocated_draw=self.allocated_draw, allocated_draw=self.allocated_draw,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def clean(self): def clean(self):
super().clean() super().clean()
@ -263,7 +287,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
if self.maximum_draw is not None and self.allocated_draw is not None: if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw: if self.allocated_draw > self.maximum_draw:
raise ValidationError({ raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." 'allocated_draw': _("Allocated draw cannot exceed the maximum draw ({maximum_draw}W).").format(maximum_draw=self.maximum_draw)
}) })
def to_yaml(self): def to_yaml(self):
@ -282,6 +306,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
A template for a PowerOutlet to be created for a new Device. A template for a PowerOutlet to be created for a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
blank=True blank=True
@ -294,14 +319,19 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
related_name='poweroutlet_templates' related_name='poweroutlet_templates'
) )
feed_leg = models.CharField( feed_leg = models.CharField(
verbose_name=_('feed leg'),
max_length=50, max_length=50,
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
blank=True, blank=True,
help_text=_("Phase (for three-phase feeds)") help_text=_('Phase (for three-phase feeds)')
) )
component_model = PowerOutlet component_model = PowerOutlet
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('power outlet template')
verbose_name_plural = _('power outlet templates')
def clean(self): def clean(self):
super().clean() super().clean()
@ -309,11 +339,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
if self.power_port: if self.power_port:
if self.device_type and self.power_port.device_type != self.device_type: if self.device_type and self.power_port.device_type != self.device_type:
raise ValidationError( raise ValidationError(
f"Parent power port ({self.power_port}) must belong to the same device type" _("Parent power port ({power_port}) must belong to the same device type").format(power_port=self.power_port)
) )
if self.module_type and self.power_port.module_type != self.module_type: if self.module_type and self.power_port.module_type != self.module_type:
raise ValidationError( raise ValidationError(
f"Parent power port ({self.power_port}) must belong to the same module type" _("Parent power port ({power_port}) must belong to the same module type").format(power_port=self.power_port)
) )
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
@ -330,6 +360,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
feed_leg=self.feed_leg, feed_leg=self.feed_leg,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -354,15 +385,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
blank=True blank=True
) )
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=InterfaceTypeChoices choices=InterfaceTypeChoices
) )
enabled = models.BooleanField( enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True default=True
) )
mgmt_only = models.BooleanField( mgmt_only = models.BooleanField(
default=False, default=False,
verbose_name='Management only' verbose_name=_('management only')
) )
bridge = models.ForeignKey( bridge = models.ForeignKey(
to='self', to='self',
@ -370,36 +403,51 @@ class InterfaceTemplate(ModularComponentTemplateModel):
related_name='bridge_interfaces', related_name='bridge_interfaces',
null=True, null=True,
blank=True, blank=True,
verbose_name='Bridge interface' verbose_name=_('bridge interface')
) )
poe_mode = models.CharField( poe_mode = models.CharField(
max_length=50, max_length=50,
choices=InterfacePoEModeChoices, choices=InterfacePoEModeChoices,
blank=True, blank=True,
verbose_name='PoE mode' verbose_name=_('PoE mode')
) )
poe_type = models.CharField( poe_type = models.CharField(
max_length=50, max_length=50,
choices=InterfacePoETypeChoices, choices=InterfacePoETypeChoices,
blank=True, blank=True,
verbose_name='PoE type' verbose_name=_('PoE type')
)
rf_role = models.CharField(
max_length=30,
choices=WirelessRoleChoices,
blank=True,
verbose_name=_('wireless role')
) )
component_model = Interface component_model = Interface
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('interface template')
verbose_name_plural = _('interface templates')
def clean(self): def clean(self):
super().clean() super().clean()
if self.bridge: if self.bridge:
if self.pk and self.bridge_id == self.pk: if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
if self.device_type and self.device_type != self.bridge.device_type: if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({ raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type" 'bridge': _("Bridge interface ({bridge}) must belong to the same device type").format(bridge=self.bridge)
}) })
if self.module_type and self.module_type != self.bridge.module_type: if self.module_type and self.module_type != self.bridge.module_type:
raise ValidationError({ raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type" 'bridge': _("Bridge interface ({bridge}) must belong to the same module type").format(bridge=self.bridge)
})
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'rf_role': "Wireless role may be set only on wireless interfaces."
}) })
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
@ -411,8 +459,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
mgmt_only=self.mgmt_only, mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode, poe_mode=self.poe_mode,
poe_type=self.poe_type, poe_type=self.poe_type,
rf_role=self.rf_role,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -425,6 +475,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
'bridge': self.bridge.name if self.bridge else None, 'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode, 'poe_mode': self.poe_mode,
'poe_type': self.poe_type, 'poe_type': self.poe_type,
'rf_role': self.rf_role,
} }
@ -433,10 +484,12 @@ class FrontPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the front of a new Device. Template for a pass-through port on the front of a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PortTypeChoices choices=PortTypeChoices
) )
color = ColorField( color = ColorField(
verbose_name=_('color'),
blank=True blank=True
) )
rear_port = models.ForeignKey( rear_port = models.ForeignKey(
@ -445,6 +498,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
related_name='frontport_templates' related_name='frontport_templates'
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(REARPORT_POSITIONS_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
@ -469,6 +523,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_rear_port_position' name='%(app_label)s_%(class)s_unique_rear_port_position'
), ),
) )
verbose_name = _('front port template')
verbose_name_plural = _('front port templates')
def clean(self): def clean(self):
super().clean() super().clean()
@ -478,13 +534,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment # Validate rear port assignment
if self.rear_port.device_type != self.device_type: if self.rear_port.device_type != self.device_type:
raise ValidationError( raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port) _("Rear port ({}) must belong to the same device type").format(self.rear_port)
) )
# Validate rear port position assignment # Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions: if self.rear_port_position > self.rear_port.positions:
raise ValidationError( raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format( _("Invalid rear port position ({}); rear port {} has only {} positions").format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions self.rear_port_position, self.rear_port.name, self.rear_port.positions
) )
) )
@ -507,6 +563,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
rear_port_position=self.rear_port_position, rear_port_position=self.rear_port_position,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -525,13 +582,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the rear of a new Device. Template for a pass-through port on the rear of a new Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PortTypeChoices choices=PortTypeChoices
) )
color = ColorField( color = ColorField(
verbose_name=_('color'),
blank=True blank=True
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(REARPORT_POSITIONS_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
@ -541,6 +601,10 @@ class RearPortTemplate(ModularComponentTemplateModel):
component_model = RearPort component_model = RearPort
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('rear port template')
verbose_name_plural = _('rear port templates')
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -550,6 +614,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
positions=self.positions, positions=self.positions,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -567,6 +632,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
A template for a ModuleBay to be created for a new parent Device. A template for a ModuleBay to be created for a new parent Device.
""" """
position = models.CharField( position = models.CharField(
verbose_name=_('position'),
max_length=30, max_length=30,
blank=True, blank=True,
help_text=_('Identifier to reference when renaming installed components') help_text=_('Identifier to reference when renaming installed components')
@ -574,6 +640,10 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay component_model = ModuleBay
class Meta(ComponentTemplateModel.Meta):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def instantiate(self, device): def instantiate(self, device):
return self.component_model( return self.component_model(
device=device, device=device,
@ -581,6 +651,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
label=self.label, label=self.label,
position=self.position position=self.position
) )
instantiate.do_not_call_in_templates = True
def to_yaml(self): def to_yaml(self):
return { return {
@ -597,17 +668,22 @@ class DeviceBayTemplate(ComponentTemplateModel):
""" """
component_model = DeviceBay component_model = DeviceBay
class Meta(ComponentTemplateModel.Meta):
verbose_name = _('device bay template')
verbose_name_plural = _('device bay templates')
def instantiate(self, device): def instantiate(self, device):
return self.component_model( return self.component_model(
device=device, device=device,
name=self.name, name=self.name,
label=self.label label=self.label
) )
instantiate.do_not_call_in_templates = True
def clean(self): def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
raise ValidationError( raise ValidationError(
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." _("Subdevice role of device type ({device_type}) must be set to \"parent\" to allow device bays.").format(device_type=self.device_type)
) )
def to_yaml(self): def to_yaml(self):
@ -662,7 +738,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
) )
part_id = models.CharField( part_id = models.CharField(
max_length=50, max_length=50,
verbose_name='Part ID', verbose_name=_('part ID'),
blank=True, blank=True,
help_text=_('Manufacturer-assigned part identifier') help_text=_('Manufacturer-assigned part identifier')
) )
@ -678,6 +754,8 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
name='%(app_label)s_%(class)s_unique_device_type_parent_name' name='%(app_label)s_%(class)s_unique_device_type_parent_name'
), ),
) )
verbose_name = _('inventory item template')
verbose_name_plural = _('inventory item templates')
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
@ -696,3 +774,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
part_id=self.part_id, part_id=self.part_id,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True

View File

@ -7,7 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import Sum
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from wireless.choices import * from wireless.choices import *
from wireless.utils import get_channel_attr from wireless.utils import get_channel_attr
@ -51,6 +52,7 @@ class ComponentModel(NetBoxModel):
related_name='%(class)ss' related_name='%(class)ss'
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'),
max_length=64 max_length=64
) )
_name = NaturalOrderingField( _name = NaturalOrderingField(
@ -59,11 +61,13 @@ class ComponentModel(NetBoxModel):
blank=True blank=True
) )
label = models.CharField( label = models.CharField(
verbose_name=_('label'),
max_length=64, max_length=64,
blank=True, blank=True,
help_text=_("Physical label") help_text=_('Physical label')
) )
description = models.CharField( description = models.CharField(
verbose_name=_('description'),
max_length=200, max_length=200,
blank=True blank=True
) )
@ -100,7 +104,7 @@ class ComponentModel(NetBoxModel):
# Check list of Modules that allow device field to be changed # Check list of Modules that allow device field to be changed
if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id): if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
raise ValidationError({ raise ValidationError({
"device": "Components cannot be moved to a different device." "device": _("Components cannot be moved to a different device.")
}) })
@property @property
@ -139,13 +143,15 @@ class CabledObjectModel(models.Model):
null=True null=True
) )
cable_end = models.CharField( cable_end = models.CharField(
verbose_name=_('cable end'),
max_length=1, max_length=1,
blank=True, blank=True,
choices=CableEndChoices choices=CableEndChoices
) )
mark_connected = models.BooleanField( mark_connected = models.BooleanField(
verbose_name=_('mark connected'),
default=False, default=False,
help_text=_("Treat as if a cable is connected") help_text=_('Treat as if a cable is connected')
) )
cable_terminations = GenericRelation( cable_terminations = GenericRelation(
@ -163,15 +169,15 @@ class CabledObjectModel(models.Model):
if self.cable and not self.cable_end: if self.cable and not self.cable_end:
raise ValidationError({ raise ValidationError({
"cable_end": "Must specify cable end (A or B) when attaching a cable." "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
}) })
if self.cable_end and not self.cable: if self.cable_end and not self.cable:
raise ValidationError({ raise ValidationError({
"cable_end": "Cable end must not be set without a cable." "cable_end": _("Cable end must not be set without a cable.")
}) })
if self.mark_connected and self.cable: if self.mark_connected and self.cable:
raise ValidationError({ raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached." "mark_connected": _("Cannot mark as connected with a cable attached.")
}) })
@property @property
@ -194,7 +200,9 @@ class CabledObjectModel(models.Model):
@property @property
def parent_object(self): def parent_object(self):
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property") raise NotImplementedError(
_("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
)
@property @property
def opposite_cable_end(self): def opposite_cable_end(self):
@ -269,17 +277,19 @@ class PathEndpoint(models.Model):
# Console components # Console components
# #
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
blank=True, blank=True,
help_text=_('Physical port type') help_text=_('Physical port type')
) )
speed = models.PositiveIntegerField( speed = models.PositiveIntegerField(
verbose_name=_('speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
blank=True, blank=True,
null=True, null=True,
@ -288,21 +298,27 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed') clone_fields = ('device', 'module', 'type', 'speed')
class Meta(ModularComponentModel.Meta):
verbose_name = _('console port')
verbose_name_plural = _('console ports')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:consoleport', kwargs={'pk': self.pk}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
blank=True, blank=True,
help_text=_('Physical port type') help_text=_('Physical port type')
) )
speed = models.PositiveIntegerField( speed = models.PositiveIntegerField(
verbose_name=_('speed'),
choices=ConsolePortSpeedChoices, choices=ConsolePortSpeedChoices,
blank=True, blank=True,
null=True, null=True,
@ -311,6 +327,10 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed') clone_fields = ('device', 'module', 'type', 'speed')
class Meta(ModularComponentModel.Meta):
verbose_name = _('console server port')
verbose_name_plural = _('console server ports')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
@ -319,31 +339,38 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components # Power components
# #
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
blank=True, blank=True,
help_text=_('Physical port type') help_text=_('Physical port type')
) )
maximum_draw = models.PositiveIntegerField( maximum_draw = models.PositiveIntegerField(
verbose_name=_('maximum draw'),
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)") help_text=_("Maximum power draw (watts)")
) )
allocated_draw = models.PositiveIntegerField( allocated_draw = models.PositiveIntegerField(
verbose_name=_('allocated draw'),
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
help_text=_("Allocated power draw (watts)") help_text=_('Allocated power draw (watts)')
) )
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
class Meta(ModularComponentModel.Meta):
verbose_name = _('power port')
verbose_name_plural = _('power ports')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:powerport', kwargs={'pk': self.pk}) return reverse('dcim:powerport', kwargs={'pk': self.pk})
@ -353,7 +380,9 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
if self.maximum_draw is not None and self.allocated_draw is not None: if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw: if self.allocated_draw > self.maximum_draw:
raise ValidationError({ raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." 'allocated_draw': _(
"Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
).format(maximum_draw=self.maximum_draw)
}) })
def get_downstream_powerports(self, leg=None): def get_downstream_powerports(self, leg=None):
@ -428,11 +457,12 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
} }
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
blank=True, blank=True,
@ -446,14 +476,19 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
related_name='poweroutlets' related_name='poweroutlets'
) )
feed_leg = models.CharField( feed_leg = models.CharField(
verbose_name=_('feed leg'),
max_length=50, max_length=50,
choices=PowerOutletFeedLegChoices, choices=PowerOutletFeedLegChoices,
blank=True, blank=True,
help_text=_("Phase (for three-phase feeds)") help_text=_('Phase (for three-phase feeds)')
) )
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
class Meta(ModularComponentModel.Meta):
verbose_name = _('power outlet')
verbose_name_plural = _('power outlets')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
@ -462,7 +497,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Validate power port assignment # Validate power port assignment
if self.power_port and self.power_port.device != self.device: if self.power_port and self.power_port.device != self.device:
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device") raise ValidationError(
_("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
)
# #
@ -474,12 +511,13 @@ class BaseInterface(models.Model):
Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface. Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
""" """
enabled = models.BooleanField( enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True default=True
) )
mac_address = MACAddressField( mac_address = MACAddressField(
null=True, null=True,
blank=True, blank=True,
verbose_name='MAC Address' verbose_name=_('MAC address')
) )
mtu = models.PositiveIntegerField( mtu = models.PositiveIntegerField(
blank=True, blank=True,
@ -488,13 +526,14 @@ class BaseInterface(models.Model):
MinValueValidator(INTERFACE_MTU_MIN), MinValueValidator(INTERFACE_MTU_MIN),
MaxValueValidator(INTERFACE_MTU_MAX) MaxValueValidator(INTERFACE_MTU_MAX)
], ],
verbose_name='MTU' verbose_name=_('MTU')
) )
mode = models.CharField( mode = models.CharField(
verbose_name=_('mode'),
max_length=50, max_length=50,
choices=InterfaceModeChoices, choices=InterfaceModeChoices,
blank=True, blank=True,
help_text=_("IEEE 802.1Q tagging strategy") help_text=_('IEEE 802.1Q tagging strategy')
) )
parent = models.ForeignKey( parent = models.ForeignKey(
to='self', to='self',
@ -502,7 +541,7 @@ class BaseInterface(models.Model):
related_name='child_interfaces', related_name='child_interfaces',
null=True, null=True,
blank=True, blank=True,
verbose_name='Parent interface' verbose_name=_('parent interface')
) )
bridge = models.ForeignKey( bridge = models.ForeignKey(
to='self', to='self',
@ -510,7 +549,7 @@ class BaseInterface(models.Model):
related_name='bridge_interfaces', related_name='bridge_interfaces',
null=True, null=True,
blank=True, blank=True,
verbose_name='Bridge interface' verbose_name=_('bridge interface')
) )
class Meta: class Meta:
@ -537,7 +576,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count() return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint): class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
""" """
@ -558,23 +597,25 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='member_interfaces', related_name='member_interfaces',
null=True, null=True,
blank=True, blank=True,
verbose_name='Parent LAG' verbose_name=_('parent LAG')
) )
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=InterfaceTypeChoices choices=InterfaceTypeChoices
) )
mgmt_only = models.BooleanField( mgmt_only = models.BooleanField(
default=False, default=False,
verbose_name='Management only', verbose_name=_('management only'),
help_text=_('This interface is used only for out-of-band management') help_text=_('This interface is used only for out-of-band management')
) )
speed = models.PositiveIntegerField( speed = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
verbose_name='Speed (Kbps)' verbose_name=_('speed (Kbps)')
) )
duplex = models.CharField( duplex = models.CharField(
verbose_name=_('duplex'),
max_length=50, max_length=50,
blank=True, blank=True,
null=True, null=True,
@ -583,27 +624,27 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
wwn = WWNField( wwn = WWNField(
null=True, null=True,
blank=True, blank=True,
verbose_name='WWN', verbose_name=_('WWN'),
help_text=_('64-bit World Wide Name') help_text=_('64-bit World Wide Name')
) )
rf_role = models.CharField( rf_role = models.CharField(
max_length=30, max_length=30,
choices=WirelessRoleChoices, choices=WirelessRoleChoices,
blank=True, blank=True,
verbose_name='Wireless role' verbose_name=_('wireless role')
) )
rf_channel = models.CharField( rf_channel = models.CharField(
max_length=50, max_length=50,
choices=WirelessChannelChoices, choices=WirelessChannelChoices,
blank=True, blank=True,
verbose_name='Wireless channel' verbose_name=_('wireless channel')
) )
rf_channel_frequency = models.DecimalField( rf_channel_frequency = models.DecimalField(
max_digits=7, max_digits=7,
decimal_places=2, decimal_places=2,
blank=True, blank=True,
null=True, null=True,
verbose_name='Channel frequency (MHz)', verbose_name=_('channel frequency (MHz)'),
help_text=_("Populated by selected channel (if set)") help_text=_("Populated by selected channel (if set)")
) )
rf_channel_width = models.DecimalField( rf_channel_width = models.DecimalField(
@ -611,26 +652,26 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
decimal_places=3, decimal_places=3,
blank=True, blank=True,
null=True, null=True,
verbose_name='Channel width (MHz)', verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)") help_text=_("Populated by selected channel (if set)")
) )
tx_power = models.PositiveSmallIntegerField( tx_power = models.PositiveSmallIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=(MaxValueValidator(127),), validators=(MaxValueValidator(127),),
verbose_name='Transmit power (dBm)' verbose_name=_('transmit power (dBm)')
) )
poe_mode = models.CharField( poe_mode = models.CharField(
max_length=50, max_length=50,
choices=InterfacePoEModeChoices, choices=InterfacePoEModeChoices,
blank=True, blank=True,
verbose_name='PoE mode' verbose_name=_('PoE mode')
) )
poe_type = models.CharField( poe_type = models.CharField(
max_length=50, max_length=50,
choices=InterfacePoETypeChoices, choices=InterfacePoETypeChoices,
blank=True, blank=True,
verbose_name='PoE type' verbose_name=_('PoE type')
) )
wireless_link = models.ForeignKey( wireless_link = models.ForeignKey(
to='wireless.WirelessLink', to='wireless.WirelessLink',
@ -643,7 +684,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
to='wireless.WirelessLAN', to='wireless.WirelessLAN',
related_name='interfaces', related_name='interfaces',
blank=True, blank=True,
verbose_name='Wireless LANs' verbose_name=_('wireless LANs')
) )
untagged_vlan = models.ForeignKey( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
@ -651,13 +692,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces_as_untagged', related_name='interfaces_as_untagged',
null=True, null=True,
blank=True, blank=True,
verbose_name='Untagged VLAN' verbose_name=_('untagged VLAN')
) )
tagged_vlans = models.ManyToManyField( tagged_vlans = models.ManyToManyField(
to='ipam.VLAN', to='ipam.VLAN',
related_name='interfaces_as_tagged', related_name='interfaces_as_tagged',
blank=True, blank=True,
verbose_name='Tagged VLANs' verbose_name=_('tagged VLANs')
) )
vrf = models.ForeignKey( vrf = models.ForeignKey(
to='ipam.VRF', to='ipam.VRF',
@ -665,7 +706,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces', related_name='interfaces',
null=True, null=True,
blank=True, blank=True,
verbose_name='VRF' verbose_name=_('VRF')
) )
ip_addresses = GenericRelation( ip_addresses = GenericRelation(
to='ipam.IPAddress', to='ipam.IPAddress',
@ -693,6 +734,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
class Meta(ModularComponentModel.Meta): class Meta(ModularComponentModel.Meta):
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
verbose_name = _('interface')
verbose_name_plural = _('interfaces')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk}) return reverse('dcim:interface', kwargs={'pk': self.pk})
@ -703,77 +746,98 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Virtual Interfaces cannot have a Cable attached # Virtual Interfaces cannot have a Cable attached
if self.is_virtual and self.cable: if self.is_virtual and self.cable:
raise ValidationError({ raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached." 'type': _("{display_type} interfaces cannot have a cable attached.").format(
display_type=self.get_type_display()
)
}) })
# Virtual Interfaces cannot be marked as connected # Virtual Interfaces cannot be marked as connected
if self.is_virtual and self.mark_connected: if self.is_virtual and self.mark_connected:
raise ValidationError({ raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." 'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
display_type=self.get_type_display())
)
}) })
# Parent validation # Parent validation
# An interface cannot be its own parent # An interface cannot be its own parent
if self.pk and self.parent_id == self.pk: if self.pk and self.parent_id == self.pk:
raise ValidationError({'parent': "An interface cannot be its own parent."}) raise ValidationError({'parent': _("An interface cannot be its own parent.")})
# A physical interface cannot have a parent interface # A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
# An interface's parent must belong to the same device or virtual chassis # An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device: if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None: if self.device.virtual_chassis is None:
raise ValidationError({ raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to a different device " 'parent': _(
f"({self.parent.device})." "The selected parent interface ({interface}) belongs to a different device ({device})"
).format(interface=self.parent, device=self.parent.device)
}) })
elif self.parent.device.virtual_chassis != self.parent.virtual_chassis: elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
raise ValidationError({ raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which " 'parent': _(
f"is not part of virtual chassis {self.device.virtual_chassis}." "The selected parent interface ({interface}) belongs to {device}, which is not part of "
"virtual chassis {virtual_chassis}."
).format(
interface=self.parent,
device=self.parent_device,
virtual_chassis=self.device.virtual_chassis
)
}) })
# Bridge validation # Bridge validation
# An interface cannot be bridged to itself # An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk: if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
# A bridged interface belong to the same device or virtual chassis # A bridged interface belong to the same device or virtual chassis
if self.bridge and self.bridge.device != self.device: if self.bridge and self.bridge.device != self.device:
if self.device.virtual_chassis is None: if self.device.virtual_chassis is None:
raise ValidationError({ raise ValidationError({
'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device " 'bridge': _("""
f"({self.bridge.device})." The selected bridge interface ({bridge}) belongs to a different device
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
}) })
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis: elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({ raise ValidationError({
'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which " 'bridge': _(
f"is not part of virtual chassis {self.device.virtual_chassis}." "The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
"chassis {virtual_chassis}."
).format(
interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
)
}) })
# LAG validation # LAG validation
# A virtual interface cannot have a parent LAG # A virtual interface cannot have a parent LAG
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
# A LAG interface cannot be its own parent # A LAG interface cannot be its own parent
if self.pk and self.lag_id == self.pk: if self.pk and self.lag_id == self.pk:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
# An interface's LAG must belong to the same device or virtual chassis # An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device: if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None: if self.device.virtual_chassis is None:
raise ValidationError({ raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})." 'lag': _(
"The selected LAG interface ({lag}) belongs to a different device ({device})."
).format(lag=self.lag, device=self.lag.device)
}) })
elif self.lag.device.virtual_chassis != self.device.virtual_chassis: elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({ raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part " 'lag': _(
f"of virtual chassis {self.device.virtual_chassis}." "The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
"{virtual_chassis}.".format(
lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
)
}) })
# PoE validation # PoE validation
@ -781,52 +845,54 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Only physical interfaces may have a PoE mode/type assigned # Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual: if self.poe_mode and self.is_virtual:
raise ValidationError({ raise ValidationError({
'poe_mode': "Virtual interfaces cannot have a PoE mode." 'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
}) })
if self.poe_type and self.is_virtual: if self.poe_type and self.is_virtual:
raise ValidationError({ raise ValidationError({
'poe_type': "Virtual interfaces cannot have a PoE type." 'poe_type': _("Virtual interfaces cannot have a PoE type.")
}) })
# An interface with a PoE type set must also specify a mode # An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode: if self.poe_type and not self.poe_mode:
raise ValidationError({ raise ValidationError({
'poe_type': "Must specify PoE mode when designating a PoE type." 'poe_type': _("Must specify PoE mode when designating a PoE type.")
}) })
# Wireless validation # Wireless validation
# RF role & channel may only be set for wireless interfaces # RF role & channel may only be set for wireless interfaces
if self.rf_role and not self.is_wireless: if self.rf_role and not self.is_wireless:
raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
if self.rf_channel and not self.is_wireless: if self.rf_channel and not self.is_wireless:
raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
# Validate channel frequency against interface type and selected channel (if any) # Validate channel frequency against interface type and selected channel (if any)
if self.rf_channel_frequency: if self.rf_channel_frequency:
if not self.is_wireless: if not self.is_wireless:
raise ValidationError({ raise ValidationError({
'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.", 'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
}) })
if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'): if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
raise ValidationError({ raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", 'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
}) })
# Validate channel width against interface type and selected channel (if any) # Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width: if self.rf_channel_width:
if not self.is_wireless: if not self.is_wireless:
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
# VLAN validation # VLAN validation
# Validate untagged VLAN # Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({ raise ValidationError({
'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " 'untagged_vlan': _("""
f"interface's parent device, or it must be global." The untagged VLAN ({untagged_vlan}) must belong to the same site as the
interface's parent device, or it must be global.
""").format(untagged_vlan=self.untagged_vlan)
}) })
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -888,15 +954,17 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Pass-through ports # Pass-through ports
# #
class FrontPort(ModularComponentModel, CabledObjectModel): class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PortTypeChoices choices=PortTypeChoices
) )
color = ColorField( color = ColorField(
verbose_name=_('color'),
blank=True blank=True
) )
rear_port = models.ForeignKey( rear_port = models.ForeignKey(
@ -905,6 +973,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
related_name='frontports' related_name='frontports'
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(REARPORT_POSITIONS_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
@ -926,6 +995,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
name='%(app_label)s_%(class)s_unique_rear_port_position' name='%(app_label)s_%(class)s_unique_rear_port_position'
), ),
) )
verbose_name = _('front port')
verbose_name_plural = _('front ports')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:frontport', kwargs={'pk': self.pk}) return reverse('dcim:frontport', kwargs={'pk': self.pk})
@ -938,29 +1009,40 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
# Validate rear port assignment # Validate rear port assignment
if self.rear_port.device != self.device: if self.rear_port.device != self.device:
raise ValidationError({ raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device" "rear_port": _(
"Rear port ({rear_port}) must belong to the same device"
).format(rear_port=self.rear_port)
}) })
# Validate rear port position assignment # Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions: if self.rear_port_position > self.rear_port.positions:
raise ValidationError({ raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " "rear_port_position": _(
f"{self.rear_port.name} has only {self.rear_port.positions} positions" "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
}) })
class RearPort(ModularComponentModel, CabledObjectModel): class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
""" """
type = models.CharField( type = models.CharField(
verbose_name=_('type'),
max_length=50, max_length=50,
choices=PortTypeChoices choices=PortTypeChoices
) )
color = ColorField( color = ColorField(
verbose_name=_('color'),
blank=True blank=True
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(REARPORT_POSITIONS_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
@ -970,6 +1052,10 @@ class RearPort(ModularComponentModel, CabledObjectModel):
) )
clone_fields = ('device', 'type', 'color', 'positions') clone_fields = ('device', 'type', 'color', 'positions')
class Meta(ModularComponentModel.Meta):
verbose_name = _('rear port')
verbose_name_plural = _('rear ports')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk}) return reverse('dcim:rearport', kwargs={'pk': self.pk})
@ -981,8 +1067,9 @@ class RearPort(ModularComponentModel, CabledObjectModel):
frontport_count = self.frontports.count() frontport_count = self.frontports.count()
if self.positions < frontport_count: if self.positions < frontport_count:
raise ValidationError({ raise ValidationError({
"positions": f"The number of positions cannot be less than the number of mapped front ports " "positions": _("""
f"({frontport_count})" The number of positions cannot be less than the number of mapped front ports
({frontport_count})""").format(frontport_count=frontport_count)
}) })
@ -990,11 +1077,12 @@ class RearPort(ModularComponentModel, CabledObjectModel):
# Bays # Bays
# #
class ModuleBay(ComponentModel): class ModuleBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
position = models.CharField( position = models.CharField(
verbose_name=_('position'),
max_length=30, max_length=30,
blank=True, blank=True,
help_text=_('Identifier to reference when renaming installed components') help_text=_('Identifier to reference when renaming installed components')
@ -1002,24 +1090,32 @@ class ModuleBay(ComponentModel):
clone_fields = ('device',) clone_fields = ('device',)
class Meta(ComponentModel.Meta):
verbose_name = _('module bay')
verbose_name_plural = _('module bays')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel, TrackingModelMixin):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
installed_device = models.OneToOneField( installed_device = models.OneToOneField(
to='dcim.Device', to='dcim.Device',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='parent_bay', related_name=_('parent_bay'),
blank=True, blank=True,
null=True null=True
) )
clone_fields = ('device',) clone_fields = ('device',)
class Meta(ComponentModel.Meta):
verbose_name = _('device bay')
verbose_name_plural = _('device bays')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicebay', kwargs={'pk': self.pk}) return reverse('dcim:devicebay', kwargs={'pk': self.pk})
@ -1028,22 +1124,22 @@ class DeviceBay(ComponentModel):
# Validate that the parent Device can have DeviceBays # Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device: if not self.device.device_type.is_parent_device:
raise ValidationError("This type of device ({}) does not support device bays.".format( raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
self.device.device_type device_type=self.device.device_type
)) ))
# Cannot install a device into itself, obviously # Cannot install a device into itself, obviously
if self.device == self.installed_device: if self.device == self.installed_device:
raise ValidationError("Cannot install a device into itself.") raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere # Check that the installed device is not already installed elsewhere
if self.installed_device: if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first() current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
if current_bay and current_bay != self: if current_bay and current_bay != self:
raise ValidationError({ raise ValidationError({
'installed_device': "Cannot install the specified device; device is already installed in {}".format( 'installed_device': _(
current_bay "Cannot install the specified device; device is already installed in {bay}."
) ).format(bay=current_bay)
}) })
@ -1057,14 +1153,20 @@ class InventoryItemRole(OrganizationalModel):
Inventory items may optionally be assigned a functional role. Inventory items may optionally be assigned a functional role.
""" """
color = ColorField( color = ColorField(
verbose_name=_('color'),
default=ColorChoices.COLOR_GREY default=ColorChoices.COLOR_GREY
) )
class Meta:
ordering = ('name',)
verbose_name = _('inventory item role')
verbose_name_plural = _('inventory item roles')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:inventoryitemrole', args=[self.pk]) return reverse('dcim:inventoryitemrole', args=[self.pk])
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes. InventoryItems are used only for inventory purposes.
@ -1109,13 +1211,13 @@ class InventoryItem(MPTTModel, ComponentModel):
) )
part_id = models.CharField( part_id = models.CharField(
max_length=50, max_length=50,
verbose_name='Part ID', verbose_name=_('part ID'),
blank=True, blank=True,
help_text=_('Manufacturer-assigned part identifier') help_text=_('Manufacturer-assigned part identifier')
) )
serial = models.CharField( serial = models.CharField(
max_length=50, max_length=50,
verbose_name='Serial number', verbose_name=_('serial number'),
blank=True blank=True
) )
asset_tag = models.CharField( asset_tag = models.CharField(
@ -1123,10 +1225,11 @@ class InventoryItem(MPTTModel, ComponentModel):
unique=True, unique=True,
blank=True, blank=True,
null=True, null=True,
verbose_name='Asset tag', verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this item') help_text=_('A unique tag used to identify this item')
) )
discovered = models.BooleanField( discovered = models.BooleanField(
verbose_name=_('discovered'),
default=False, default=False,
help_text=_('This item was automatically discovered') help_text=_('This item was automatically discovered')
) )
@ -1143,6 +1246,8 @@ class InventoryItem(MPTTModel, ComponentModel):
name='%(app_label)s_%(class)s_unique_device_parent_name' name='%(app_label)s_%(class)s_unique_device_parent_name'
), ),
) )
verbose_name = _('inventory item')
verbose_name_plural = _('inventory items')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
@ -1153,7 +1258,7 @@ class InventoryItem(MPTTModel, ComponentModel):
# An InventoryItem cannot be its own parent # An InventoryItem cannot be its own parent
if self.pk and self.parent_id == self.pk: if self.pk and self.parent_id == self.pk:
raise ValidationError({ raise ValidationError({
"parent": "Cannot assign self as parent." "parent": _("Cannot assign self as parent.")
}) })
# Validation for moving InventoryItems # Validation for moving InventoryItems
@ -1161,13 +1266,13 @@ class InventoryItem(MPTTModel, ComponentModel):
# Cannot move an InventoryItem to another device if it has a parent # Cannot move an InventoryItem to another device if it has a parent
if self.parent and self.parent.device != self.device: if self.parent and self.parent.device != self.device:
raise ValidationError({ raise ValidationError({
"parent": "Parent inventory item does not belong to the same device." "parent": _("Parent inventory item does not belong to the same device.")
}) })
# Prevent moving InventoryItems with children # Prevent moving InventoryItems with children
first_child = self.get_children().first() first_child = self.get_children().first()
if first_child and first_child.device != self.device: if first_child and first_child.device != self.device:
raise ValidationError("Cannot move an inventory item with dependent children") raise ValidationError(_("Cannot move an inventory item with dependent children"))
# When moving an InventoryItem to another device, remove any associated component # When moving an InventoryItem to another device, remove any associated component
if self.component and self.component.device != self.device: if self.component and self.component.device != self.device:
@ -1175,5 +1280,5 @@ class InventoryItem(MPTTModel, ComponentModel):
else: else:
if self.component and self.component.device != self.device: if self.component and self.component.device != self.device:
raise ValidationError({ raise ValidationError({
"device": "Cannot assign inventory item to component on another device" "device": _("Cannot assign inventory item to component on another device")
}) })

Some files were not shown because too many files have changed in this diff Show More