mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-24 17:38:37 -06:00
Merge branch 'feature' into 11466-assign-vlans-to-devices-for-l2vpn
This commit is contained in:
commit
84e70eaeb0
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.0
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.0
|
||||
placeholder: v3.6.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
name: 🌍 Translation
|
||||
description: Request support for a new language in the user interface
|
||||
labels: ["type: translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||
not use it to request changes to existing translations.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What is the name of the language in English?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: ISO 639-1 code
|
||||
description: >
|
||||
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||
assigned to the language?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Volunteer
|
||||
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Comments
|
||||
description: Any other notes you would like to share
|
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -31,15 +31,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@ -47,7 +47,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
|
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
- uses: dessant/lock-threads@v4
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v6
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<p>The premier source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
@ -23,8 +23,9 @@ django-filter
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
||||
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
|
||||
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||
django-mptt
|
||||
django-mptt==0.14.0
|
||||
|
||||
# Context managers for PostgreSQL advisory locks
|
||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
||||
@ -52,7 +53,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||
django-taggit<5.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
@ -120,9 +122,9 @@ psycopg[binary,pool]
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
PyYAML
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
# Requests
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
|
@ -342,8 +342,10 @@
|
||||
"100gbase-x-qsfpdd",
|
||||
"200gbase-x-qsfp56",
|
||||
"200gbase-x-qsfpdd",
|
||||
"400gbase-x-qsfp112",
|
||||
"400gbase-x-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"400gbase-x-osfp-rhs",
|
||||
"400gbase-x-cdfp",
|
||||
"400gbase-x-cfp8",
|
||||
"800gbase-x-qsfpdd",
|
||||
|
@ -4,27 +4,15 @@
|
||||
|
||||
### Enabling Error Reporting
|
||||
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
```
|
||||
|
||||
### Using a Custom DSN
|
||||
|
||||
If you prefer instead to use your own Sentry ingestor, you'll need to first create a new project under your Sentry account to represent your NetBox deployment and obtain its corresponding data source name (DSN). This looks like a URL similar to the example below:
|
||||
|
||||
```
|
||||
https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
```
|
||||
|
||||
Once you have obtained a DSN, configure Sentry in NetBox's `configuration.py` file with the following parameters:
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, set `SENTRY_ENABLED` to True and define your unique [data source name (DSN)](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/) in `configuration.py`.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
```
|
||||
|
||||
Setting `SENTRY_ENABLED` to False will disable the Sentry integration.
|
||||
|
||||
### Assigning Tags
|
||||
|
||||
You can optionally attach one or more arbitrary tags to the outgoing error reports if desired by setting the `SENTRY_TAGS` parameter:
|
||||
|
@ -87,3 +87,24 @@ The following colors are supported:
|
||||
* `gray`
|
||||
* `black`
|
||||
* `white`
|
||||
|
||||
---
|
||||
|
||||
## PROTECTION_RULES
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
This is a mapping of models to [custom validators](../customization/custom-validation.md) against which an object is evaluated immediately prior to its deletion. If validation fails, the object is not deleted. An example is provided below:
|
||||
|
||||
```python
|
||||
PROTECTION_RULES = {
|
||||
"dcim.site": [
|
||||
{
|
||||
"status": {
|
||||
"eq": "decommissioning"
|
||||
}
|
||||
},
|
||||
"my_plugin.validators.Validator1",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'height': 3,
|
||||
'title': 'Organization',
|
||||
'config': {
|
||||
'models': [
|
||||
@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
|
@ -18,6 +18,9 @@ Default: False
|
||||
|
||||
Set to True to enable automatic error reporting via [Sentry](https://sentry.io/).
|
||||
|
||||
!!! note
|
||||
The `sentry-sdk` Python package is required to enable Sentry integration.
|
||||
|
||||
---
|
||||
|
||||
## SENTRY_SAMPLE_RATE
|
||||
|
@ -80,6 +80,14 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
|
@ -40,14 +40,22 @@ Related custom fields can be grouped together within the UI by assigning each th
|
||||
|
||||
This parameter has no effect on the API representation of custom field data.
|
||||
|
||||
### Visibility
|
||||
### Visibility & Editing
|
||||
|
||||
When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI.
|
||||
!!! info "This feature was improved in NetBox v3.7."
|
||||
|
||||
* **Read/write** (default): The custom field is included when viewing and editing objects.
|
||||
* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.)
|
||||
When creating a custom field, users can control the conditions under which it may be displayed and edited within the NetBox user interface. The following choices are available for controlling the display of a custom field on an object:
|
||||
|
||||
* **Always** (default): The custom field is included when viewing an object.
|
||||
* **If Set**: The custom field is included only if a value has been defined for the object.
|
||||
* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users.
|
||||
|
||||
Additionally, the following options are available for controlling whether custom field values can be altered within the NetBox UI:
|
||||
|
||||
* **Yes** (default): The custom field's value may be modified when editing an object.
|
||||
* **No**: The custom field is displayed for reference when editing an object, but its value may not be modified.
|
||||
* **Hidden**: The custom field is not displayed when editing an object.
|
||||
|
||||
Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API.
|
||||
|
||||
### Validation
|
||||
|
@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
@ -26,6 +26,8 @@ The `CustomValidator` class supports several validation types:
|
||||
* `regex`: Application of a [regular expression](https://en.wikipedia.org/wiki/Regular_expression)
|
||||
* `required`: A value must be specified
|
||||
* `prohibited`: A value must _not_ be specified
|
||||
* `eq`: A value must be equal to the specified value
|
||||
* `neq`: A value must _not_ be equal to the specified value
|
||||
|
||||
The `min` and `max` types should be defined for numeric values, whereas `min_length`, `max_length`, and `regex` are suitable for character strings (text values). The `required` and `prohibited` validators may be used for any field, and should be passed a value of `True`.
|
||||
|
||||
|
@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
@ -41,6 +41,10 @@ A dictionary of particular features (e.g. custom fields) mapped to the NetBox mo
|
||||
|
||||
Supported model features are listed in the [features matrix](./models.md#features-matrix).
|
||||
|
||||
### `models`
|
||||
|
||||
This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
|
||||
|
||||
### `plugins`
|
||||
|
||||
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
|
||||
@ -49,6 +53,10 @@ This store maintains all registered items for plugins, such as navigation menus,
|
||||
|
||||
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
|
||||
|
||||
### `tables`
|
||||
|
||||
A dictionary mapping table classes to lists of extra columns that have been registered by plugins using the `register_table_column()` utility function. Each column is defined as a tuple of name and column instance.
|
||||
|
||||
### `views`
|
||||
|
||||
A hierarchical mapping of registered views for each model. Mappings are added using the `register_model_view()` decorator, and URLs paths can be generated from these using `get_model_urls()`.
|
||||
|
@ -97,7 +97,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
|
||||
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.
|
||||
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. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
|
||||
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
|
||||
|
||||
```
|
||||
@ -107,7 +107,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
<h5 class="card-header">{% trans "Circuit List" %}</h5>
|
||||
|
||||
{# A longer string with a context variable #}
|
||||
{% blocktrans with count=object.circuits.count %}
|
||||
{% blocktrans trimmed with count=object.circuits.count %}
|
||||
There are {count} circuits. Would you like to continue?
|
||||
{% endblocktrans %}
|
||||
```
|
||||
|
@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
|
||||
|
@ -1,6 +1,6 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# The Premiere Network Source of Truth
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
|
||||
|
||||
|
@ -227,6 +227,17 @@ sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
|
||||
!!! info
|
||||
These packages were previously required in NetBox v3.5 but now are optional.
|
||||
|
||||
### Sentry Integration
|
||||
|
||||
NetBox may be configured to send error reports to [Sentry](../administration/error-reporting.md) for analysis. This integration requires installation of the `sentry-sdk` Python library.
|
||||
|
||||
```no-highlight
|
||||
sudo sh -c "echo 'sentry-sdk' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
!!! info
|
||||
Sentry integration was previously included by default in NetBox v3.6 but is now optional.
|
||||
|
||||
## 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:
|
||||
|
@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
!!! warning
|
||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
||||
|
||||
## Authenticating with Active Directory
|
||||
|
||||
Integrating Active Directory for authentication can be a bit challenging as it may require handling different login formats. This solution will allow users to log in either using their full User Principal Name (UPN) or their username alone, by filtering the DN according to either the `sAMAccountName` or the `userPrincipalName`. The following configuration options will allow your users to enter their usernames in the format `username` or `username@domain.tld`.
|
||||
|
||||
Just as before, the configuration options are defined in the file ldap_config.py. First, modify the `AUTH_LDAP_USER_SEARCH` option to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
```
|
||||
|
||||
In addition, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to `None` as described in the previous sections. Next, modify `AUTH_LDAP_USER_ATTR_MAP` to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we need to add one more configuration option, `AUTH_LDAP_USER_QUERY_FIELD`. The following should be added to your LDAP configuration file:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
```
|
||||
|
||||
With these configuration options, your users will be able to log in either with or without the UPN suffix.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
!!! info
|
||||
This configuration is intended to serve as a template, but may need to be modified in accordance with your environment.
|
||||
|
||||
```python
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com:3269"
|
||||
|
||||
# The following may be needed if you are binding to Active Directory.
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0
|
||||
}
|
||||
|
||||
# Set the DN and password for the NetBox service account.
|
||||
AUTH_LDAP_BIND_DN = "CN=NETBOXSA,OU=Service Accounts,DC=example,DC=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
|
||||
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
|
||||
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
|
||||
# username is not in their DN (Active Directory).
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
|
||||
# If a user's DN is producible from their username, we don't need to search.
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = None
|
||||
|
||||
# You can map user attributes to Django attributes as so.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# hierarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
"dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
|
||||
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Mirror LDAP group assignments.
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
|
||||
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# For more granular permissions, we can map LDAP groups to Django groups.
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache groups for one hour to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
```
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
@ -1,5 +1,8 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
@ -77,6 +77,9 @@ If selected, this component will be treated as if a cable has been connected.
|
||||
|
||||
Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
|
||||
|
||||
!!! note
|
||||
An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
|
||||
|
||||
### Bridged Interface
|
||||
|
||||
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.
|
||||
|
@ -23,17 +23,3 @@ If designated, this platform will be available for use only to devices assigned
|
||||
### Configuration Template
|
||||
|
||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
|
||||
|
||||
### NAPALM Driver
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
|
||||
|
||||
### NAPALM Arguments
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
Any additional arguments to send when invoking the NAPALM driver assigned to this platform.
|
||||
|
@ -64,16 +64,25 @@ Defines how filters are evaluated against custom field values.
|
||||
| Loose | Match any occurrence of the value |
|
||||
| Exact | Match only the complete field value |
|
||||
|
||||
### UI Visibility
|
||||
### UI Visible
|
||||
|
||||
Controls how and whether the custom field is displayed within the NetBox user interface.
|
||||
Controls whether the custom field is displayed for objects within the NetBox user interface.
|
||||
|
||||
| Option | Description |
|
||||
|-------------------|--------------------------------------------------|
|
||||
| Read/write | Display and permit editing (default) |
|
||||
| Read-only | Display field but disallow editing |
|
||||
| Hidden | Do not display field in the UI |
|
||||
| Hidden (if unset) | Display in the UI only when a value has been set |
|
||||
| Option | Description |
|
||||
|--------|----------------------------------------------------------------|
|
||||
| Always | The field is always displayed when viewing an object (default) |
|
||||
| If set | The field is displayed only if a value has been defined |
|
||||
| Hidden | The field is not displayed when viewing an object |
|
||||
|
||||
### UI Editable
|
||||
|
||||
Controls whether the custom field is editable on objects within the NetBox user interface.
|
||||
|
||||
| Option | Description |
|
||||
|--------|------------------------------------------------------------------------------|
|
||||
| Yes | The field's value may be changed when editing an object (default) |
|
||||
| No | The field's value is displayed when editing an object but may not be altered |
|
||||
| Hidden | The field is not displayed when editing an object |
|
||||
|
||||
### Default
|
||||
|
||||
|
@ -16,6 +16,9 @@ The interface's name. Must be unique to the assigned VM.
|
||||
|
||||
Identifies the parent interface of a subinterface (e.g. used to employ encapsulation).
|
||||
|
||||
!!! note
|
||||
An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
|
||||
|
||||
### Bridged Interface
|
||||
|
||||
An interface on the same VM with which this interface is bridged.
|
||||
|
23
docs/plugins/development/data-backends.md
Normal file
23
docs/plugins/development/data-backends.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Data Backends
|
||||
|
||||
[Data sources](../../models/core/datasource.md) can be defined to reference data which exists on systems of record outside NetBox, such as a git repository or Amazon S3 bucket. Plugins can register their own backend classes to introduce support for additional resource types. This is done by subclassing NetBox's `DataBackend` class.
|
||||
|
||||
```python title="data_backends.py"
|
||||
from netbox.data_backends import DataBackend
|
||||
|
||||
class MyDataBackend(DataBackend):
|
||||
name = 'mybackend'
|
||||
label = 'My Backend'
|
||||
...
|
||||
```
|
||||
|
||||
To register one or more data backends with NetBox, define a list named `backends` at the end of this file:
|
||||
|
||||
```python title="data_backends.py"
|
||||
backends = [MyDataBackend]
|
||||
```
|
||||
|
||||
!!! tip
|
||||
The path to the list of search indexes can be modified by setting `data_backends` in the PluginConfig instance.
|
||||
|
||||
::: core.data_backends.DataBackend
|
@ -109,6 +109,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
|
||||
| `queues` | A list of custom background task queues to create |
|
||||
| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
|
||||
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
|
||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
|
||||
|
@ -64,12 +64,15 @@ item1 = PluginMenuItem(
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
|
||||
!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1."
|
||||
|
||||
## Menu Buttons
|
||||
|
||||
|
@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'device', 'status', 'description')
|
||||
```
|
||||
|
||||
Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
|
||||
|
||||
To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
|
||||
|
||||
```python
|
||||
|
@ -87,3 +87,28 @@ The table column classes listed below are supported for use in plugins. These cl
|
||||
options:
|
||||
members:
|
||||
- __init__
|
||||
|
||||
## Extending Core Tables
|
||||
|
||||
!!! info "This feature was introduced in NetBox v3.7."
|
||||
|
||||
Plugins can register their own custom columns on core tables using the `register_table_column()` utility function. This allows a plugin to attach additional information, such as relationships to its own models, to built-in object lists.
|
||||
|
||||
```python
|
||||
import django_tables2
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.tables import SiteTable
|
||||
from utilities.tables import register_table_column
|
||||
|
||||
mycol = django_tables2.Column(
|
||||
verbose_name=_('My Column'),
|
||||
accessor=django_tables2.A('description')
|
||||
)
|
||||
|
||||
register_table_column(mycol, 'foo', SiteTable)
|
||||
```
|
||||
|
||||
You'll typically want to define an accessor identifying the desired model field or relationship when defining a custom column. See the [django-tables2 documentation](https://django-tables2.readthedocs.io/) for more information on creating custom columns.
|
||||
|
||||
::: utilities.tables.register_table_column
|
||||
|
@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
]
|
||||
},
|
||||
{
|
||||
"attr": "tags",
|
||||
"attr": "tags.slug",
|
||||
"value": "exempt",
|
||||
"op": "contains"
|
||||
}
|
||||
|
@ -1,6 +1,148 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.1 (FUTURE)
|
||||
## v3.6.6 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||
|
||||
---
|
||||
|
||||
## v3.6.4 (2023-10-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
|
||||
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
|
||||
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
|
||||
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
|
||||
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
|
||||
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
|
||||
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
|
||||
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
|
||||
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
|
||||
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
|
||||
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
|
||||
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
|
||||
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
|
||||
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
|
||||
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
|
||||
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
|
||||
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
|
||||
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
|
||||
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
|
||||
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
|
||||
|
||||
---
|
||||
|
||||
## v3.6.3 (2023-09-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
|
||||
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
|
||||
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
|
||||
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
|
||||
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
|
||||
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
|
||||
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
|
||||
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
|
||||
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
|
||||
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
|
||||
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
|
||||
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
|
||||
|
||||
---
|
||||
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
|
||||
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
|
||||
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
|
||||
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
|
||||
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
|
||||
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
|
||||
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
|
||||
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
|
||||
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
|
||||
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
|
||||
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
|
||||
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
|
||||
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
|
||||
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
|
||||
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
|
||||
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
|
||||
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
|
||||
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
|
||||
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
|
||||
|
||||
---
|
||||
|
||||
## v3.6.1 (2023-09-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12870](https://github.com/netbox-community/netbox/issues/12870) - Support setting token expiration time using the provisioning API endpoint
|
||||
* [#13444](https://github.com/netbox-community/netbox/issues/13444) - Add bulk rename functionality to the global device component lists
|
||||
* [#13638](https://github.com/netbox-community/netbox/issues/13638) - Add optional `staff_only` attribute to MenuItem
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#12553](https://github.com/netbox-community/netbox/issues/12552) - Ensure `family` attribute is always returned when creating aggregates and prefixes via REST API
|
||||
* [#13619](https://github.com/netbox-community/netbox/issues/13619) - Fix exception when viewing IP address assigned to a virtual machine
|
||||
* [#13596](https://github.com/netbox-community/netbox/issues/13596) - Always display "render config" tab for devices and virtual machines
|
||||
* [#13620](https://github.com/netbox-community/netbox/issues/13620) - Show admin menu items only for staff users
|
||||
* [#13622](https://github.com/netbox-community/netbox/issues/13622) - Fix exception when viewing current config and no revisions have been created
|
||||
* [#13626](https://github.com/netbox-community/netbox/issues/13626) - Correct filtering of recent activity list under user view
|
||||
* [#13628](https://github.com/netbox-community/netbox/issues/13628) - Remove stale references to obsolete NAPALM integration
|
||||
* [#13630](https://github.com/netbox-community/netbox/issues/13630) - Fix display of active status under user view
|
||||
* [#13632](https://github.com/netbox-community/netbox/issues/13632) - Avoid raising exception when checking if FHRP group IP address is primary
|
||||
* [#13642](https://github.com/netbox-community/netbox/issues/13642) - Suppress warning about unreflected model changes when applying migrations
|
||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
||||
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
|
||||
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
@ -9,6 +151,7 @@
|
||||
### Breaking Changes
|
||||
|
||||
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
|
||||
* The `boto3` and `dulwich` packages are no longer installed automatically. If needed for S3/git remote data backend support, add them to `local_requirements.txt` to ensure their installation.
|
||||
* 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.
|
||||
@ -89,8 +232,9 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* [#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
|
||||
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||
* [#12906](https://github.com/netbox-community/netbox/issues/12906) - The `boto3` (AWS) and `dulwich` (git) packages for remote data sources are now optional requirements
|
||||
* [#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
|
||||
|
||||
|
@ -136,6 +136,7 @@ nav:
|
||||
- Forms: 'plugins/development/forms.md'
|
||||
- Filters & Filter Sets: 'plugins/development/filtersets.md'
|
||||
- Search: 'plugins/development/search.md'
|
||||
- Data Backends: 'plugins/development/data-backends.md'
|
||||
- REST API: 'plugins/development/rest-api.md'
|
||||
- GraphQL API: 'plugins/development/graphql-api.md'
|
||||
- Background Tasks: 'plugins/development/background-tasks.md'
|
||||
|
@ -7,6 +7,8 @@ class UserToken(Token):
|
||||
"""
|
||||
Proxy model for users to manage their own API tokens.
|
||||
"""
|
||||
_netbox_private = True
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = 'token'
|
||||
|
@ -85,7 +85,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'circuit_count',
|
||||
]
|
||||
|
||||
|
@ -137,7 +137,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
fields = ['id', 'name', 'slug', 'color', 'description']
|
||||
|
||||
|
||||
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
@ -154,12 +154,12 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
provider_account_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='provider_account',
|
||||
queryset=ProviderAccount.objects.all(),
|
||||
label=_('ProviderAccount (ID)'),
|
||||
label=_('Provider account (ID)'),
|
||||
)
|
||||
provider_network_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='terminations__provider_network',
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
label=_('ProviderNetwork (ID)'),
|
||||
label=_('Provider network (ID)'),
|
||||
)
|
||||
type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
@ -7,7 +7,7 @@ from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
@ -91,6 +91,10 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
@ -99,9 +103,9 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = CircuitType
|
||||
fieldsets = (
|
||||
(None, ('description',)),
|
||||
(None, ('color', 'description')),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
nullable_fields = ('color', 'description')
|
||||
|
||||
|
||||
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
@ -3,6 +3,7 @@ from django import forms
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Site
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
@ -64,7 +65,10 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ('name', 'slug', 'description', 'tags')
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
class CircuitImportForm(NetBoxModelImportForm):
|
||||
|
@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
@ -88,7 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
|
||||
label=_('Provider')
|
||||
)
|
||||
service_id = forms.CharField(
|
||||
label=_('Service id'),
|
||||
label=_('Service ID'),
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
@ -97,8 +97,17 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
model = CircuitType
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('color',)),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Circuit
|
||||
|
@ -76,14 +76,14 @@ class CircuitTypeForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
(_('Circuit Type'), (
|
||||
'name', 'slug', 'description', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'name', 'slug', 'description', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal file
18
netbox/circuits/migrations/0043_circuittype_color.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.5 on 2023-10-20 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('circuits', '0042_provideraccount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuittype',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(blank=True, max_length=6),
|
||||
),
|
||||
]
|
@ -7,6 +7,7 @@ from circuits.choices import *
|
||||
from dcim.models import CabledObjectModel
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
|
||||
from utilities.fields import ColorField
|
||||
|
||||
__all__ = (
|
||||
'Circuit',
|
||||
@ -20,6 +21,11 @@ class CircuitType(OrganizationalModel):
|
||||
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
|
||||
"Long Haul," "Metro," or "Out-of-Band".
|
||||
"""
|
||||
color = ColorField(
|
||||
verbose_name=_('color'),
|
||||
blank=True
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuittype', args=[self.pk])
|
||||
|
||||
|
@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
|
||||
('port_speed', 2000),
|
||||
('upstream_speed', 2000),
|
||||
)
|
||||
display_attrs = ('circuit', 'site', 'provider_network', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
class ProviderAccountIndex(SearchIndex):
|
||||
@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
|
||||
('account', 200),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'account', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('provider', 'service_id', 'description')
|
||||
|
@ -28,6 +28,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Name'),
|
||||
)
|
||||
color = columns.ColorColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='circuits:circuittype_list'
|
||||
)
|
||||
@ -40,7 +41,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CircuitType
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
@ -4,6 +4,7 @@ from core.choices import *
|
||||
from core.models import *
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from .nested_serializers import *
|
||||
|
||||
@ -19,7 +20,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
||||
view_name='core-api:datasource-detail'
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=DataSourceTypeChoices
|
||||
choices=get_data_backend_choices()
|
||||
)
|
||||
status = ChoiceField(
|
||||
choices=DataSourceStatusChoices,
|
||||
@ -68,5 +69,5 @@ class JobSerializer(BaseModelSerializer):
|
||||
model = Job
|
||||
fields = [
|
||||
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
|
||||
'started', 'completed', 'user', 'data', 'job_id',
|
||||
'started', 'completed', 'user', 'data', 'error', 'job_id',
|
||||
]
|
||||
|
@ -1,4 +1,15 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
|
||||
|
||||
# Use our custom destructor to ignore certain attributes when calculating field migrations
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
|
@ -7,18 +7,6 @@ from utilities.choices import ChoiceSet
|
||||
# Data sources
|
||||
#
|
||||
|
||||
class DataSourceTypeChoices(ChoiceSet):
|
||||
LOCAL = 'local'
|
||||
GIT = 'git'
|
||||
AMAZON_S3 = 'amazon-s3'
|
||||
|
||||
CHOICES = (
|
||||
(LOCAL, _('Local'), 'gray'),
|
||||
(GIT, _('Git'), 'blue'),
|
||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
||||
)
|
||||
|
||||
|
||||
class DataSourceStatusChoices(ChoiceSet):
|
||||
NEW = 'new'
|
||||
QUEUED = 'queued'
|
||||
|
@ -10,61 +10,24 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.registry import registry
|
||||
from .choices import DataSourceTypeChoices
|
||||
from netbox.data_backends import DataBackend
|
||||
from netbox.utils import register_data_backend
|
||||
from .exceptions import SyncError
|
||||
|
||||
__all__ = (
|
||||
'LocalBackend',
|
||||
'GitBackend',
|
||||
'LocalBackend',
|
||||
'S3Backend',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
def register_backend(name):
|
||||
"""
|
||||
Decorator for registering a DataBackend class.
|
||||
"""
|
||||
|
||||
def _wrapper(cls):
|
||||
registry['data_backends'][name] = cls
|
||||
return cls
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
class DataBackend:
|
||||
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):
|
||||
self.url = url
|
||||
self.params = kwargs
|
||||
self.config = self.init_config()
|
||||
|
||||
def init_config(self):
|
||||
"""
|
||||
Hook to initialize the instance's configuration.
|
||||
"""
|
||||
return
|
||||
|
||||
@property
|
||||
def url_scheme(self):
|
||||
return urlparse(self.url).scheme.lower()
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
raise NotImplemented()
|
||||
|
||||
|
||||
@register_backend(DataSourceTypeChoices.LOCAL)
|
||||
@register_data_backend()
|
||||
class LocalBackend(DataBackend):
|
||||
name = 'local'
|
||||
label = _('Local')
|
||||
is_local = True
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
@ -74,20 +37,22 @@ class LocalBackend(DataBackend):
|
||||
yield local_path
|
||||
|
||||
|
||||
@register_backend(DataSourceTypeChoices.GIT)
|
||||
@register_data_backend()
|
||||
class GitBackend(DataBackend):
|
||||
name = 'git'
|
||||
label = 'Git'
|
||||
parameters = {
|
||||
'username': forms.CharField(
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
@ -144,8 +109,10 @@ class GitBackend(DataBackend):
|
||||
local_path.cleanup()
|
||||
|
||||
|
||||
@register_backend(DataSourceTypeChoices.AMAZON_S3)
|
||||
@register_data_backend()
|
||||
class S3Backend(DataBackend):
|
||||
name = 'amazon-s3'
|
||||
label = 'Amazon S3'
|
||||
parameters = {
|
||||
'aws_access_key_id': forms.CharField(
|
||||
label=_('AWS access key ID'),
|
||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
import django_filters
|
||||
|
||||
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
@ -16,7 +17,7 @@ __all__ = (
|
||||
|
||||
class DataSourceFilterSet(NetBoxModelFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=DataSourceTypeChoices,
|
||||
choices=get_data_backend_choices,
|
||||
null_value=None
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
|
@ -1,10 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import DataSourceTypeChoices
|
||||
from core.models import *
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from utilities.forms import add_blank_choice
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
|
||||
@ -16,9 +15,8 @@ __all__ = (
|
||||
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
type = forms.ChoiceField(
|
||||
label=_('Type'),
|
||||
choices=add_blank_choice(DataSourceTypeChoices),
|
||||
required=False,
|
||||
initial=''
|
||||
choices=get_data_backend_choices,
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
|
@ -1,13 +1,12 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import *
|
||||
from core.models import *
|
||||
from extras.forms.mixins import SavedFiltersMixin
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm
|
||||
from utilities.forms.fields import ContentTypeChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
@ -27,7 +26,7 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=DataSourceTypeChoices,
|
||||
choices=get_data_backend_choices,
|
||||
required=False
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
@ -68,7 +67,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
object_type = ContentTypeChoiceField(
|
||||
label=_('Object Type'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('jobs').get_query()),
|
||||
queryset=ContentType.objects.with_feature('jobs'),
|
||||
required=False,
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
|
@ -7,6 +7,7 @@ from core.forms.mixins import SyncedDataMixin
|
||||
from core.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from netbox.registry import registry
|
||||
from netbox.utils import get_data_backend_choices
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import CommentField
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
@ -18,6 +19,10 @@ __all__ = (
|
||||
|
||||
|
||||
class DataSourceForm(NetBoxModelForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=get_data_backend_choices,
|
||||
widget=HTMXSelect()
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
@ -26,7 +31,6 @@ class DataSourceForm(NetBoxModelForm):
|
||||
'name', 'type', 'source_url', 'enabled', 'description', 'comments', 'ignore_rules', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'type': HTMXSelect(),
|
||||
'ignore_rules': forms.Textarea(
|
||||
attrs={
|
||||
'rows': 5,
|
||||
@ -56,12 +60,13 @@ class DataSourceForm(NetBoxModelForm):
|
||||
|
||||
# Add backend-specific form fields
|
||||
self.backend_fields = []
|
||||
for name, form_field in backend.parameters.items():
|
||||
field_name = f'backend_{name}'
|
||||
self.backend_fields.append(field_name)
|
||||
self.fields[field_name] = copy.copy(form_field)
|
||||
if self.instance and self.instance.parameters:
|
||||
self.fields[field_name].initial = self.instance.parameters.get(name)
|
||||
if backend:
|
||||
for name, form_field in backend.parameters.items():
|
||||
field_name = f'backend_{name}'
|
||||
self.backend_fields.append(field_name)
|
||||
self.fields[field_name] = copy.copy(form_field)
|
||||
if self.instance and self.instance.parameters:
|
||||
self.fields[field_name].initial = self.instance.parameters.get(name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
|
@ -25,7 +25,7 @@ def sync_datasource(job, *args, **kwargs):
|
||||
job.terminate()
|
||||
|
||||
except Exception as e:
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e))
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
if type(e) in (SyncError, JobTimeoutException):
|
||||
logging.error(e)
|
||||
|
@ -1,11 +1,20 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command to clear the entire cache."""
|
||||
help = 'Clears the cache.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Fetch the current config revision from the cache
|
||||
config_version = cache.get('config_version')
|
||||
# Clear the cache
|
||||
cache.clear()
|
||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||
if config_version:
|
||||
# Activate the current config revision
|
||||
ConfigRevision.objects.get(id=config_version).activate()
|
||||
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||
|
@ -1,18 +1,6 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.conf import settings
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.management.commands.makemigrations import Command as _Command
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Command(_Command):
|
||||
|
@ -1,7 +0,0 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.core.management.commands.migrate import Command
|
||||
from django.db import models
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
models.Field.deconstruct = custom_deconstruct
|
@ -4,7 +4,6 @@ from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import extras.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -30,7 +29,7 @@ class Migration(migrations.Migration):
|
||||
('status', models.CharField(default='pending', max_length=30)),
|
||||
('data', models.JSONField(blank=True, null=True)),
|
||||
('job_id', models.UUIDField(unique=True)),
|
||||
('object_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
|
||||
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='contenttypes.contenttype')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
|
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.6 on 2023-10-20 17:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_job_created_auto_now'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='datasource',
|
||||
name='type',
|
||||
field=models.CharField(max_length=50),
|
||||
),
|
||||
]
|
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
18
netbox/core/migrations/0007_job_add_error_field.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.6 on 2023-10-23 20:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_datasource_type_remove_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='job',
|
||||
name='error',
|
||||
field=models.TextField(blank=True, editable=False),
|
||||
),
|
||||
]
|
29
netbox/core/migrations/0008_contenttype_proxy.py
Normal file
29
netbox/core/migrations/0008_contenttype_proxy.py
Normal file
@ -0,0 +1,29 @@
|
||||
# Generated by Django 4.2.6 on 2023-10-31 19:38
|
||||
|
||||
import core.models.contenttypes
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('core', '0007_job_add_error_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContentType',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('contenttypes.contenttype',),
|
||||
managers=[
|
||||
('objects', core.models.contenttypes.ContentTypeManager()),
|
||||
],
|
||||
),
|
||||
]
|
@ -1,3 +1,4 @@
|
||||
from .contenttypes import *
|
||||
from .data import *
|
||||
from .files import *
|
||||
from .jobs import *
|
||||
|
50
netbox/core/models/contenttypes.py
Normal file
50
netbox/core/models/contenttypes.py
Normal file
@ -0,0 +1,50 @@
|
||||
from django.contrib.contenttypes.models import ContentType as ContentType_, ContentTypeManager as ContentTypeManager_
|
||||
from django.db.models import Q
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'ContentType',
|
||||
'ContentTypeManager',
|
||||
)
|
||||
|
||||
|
||||
class ContentTypeManager(ContentTypeManager_):
|
||||
|
||||
def public(self):
|
||||
"""
|
||||
Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed
|
||||
in registry['models'] and intended for reference by other objects.
|
||||
"""
|
||||
q = Q()
|
||||
for app_label, models in registry['models'].items():
|
||||
q |= Q(app_label=app_label, model__in=models)
|
||||
return self.get_queryset().filter(q)
|
||||
|
||||
def with_feature(self, feature):
|
||||
"""
|
||||
Return the ContentTypes only for models which are registered as supporting the specified feature. For example,
|
||||
we can find all ContentTypes for models which support webhooks with
|
||||
|
||||
ContentType.objects.with_feature('webhooks')
|
||||
"""
|
||||
if feature not in registry['model_features']:
|
||||
raise KeyError(
|
||||
f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}"
|
||||
)
|
||||
|
||||
q = Q()
|
||||
for app_label, models in registry['model_features'][feature].items():
|
||||
q |= Q(app_label=app_label, model__in=models)
|
||||
|
||||
return self.get_queryset().filter(q)
|
||||
|
||||
|
||||
class ContentType(ContentType_):
|
||||
"""
|
||||
Wrap Django's native ContentType model to use our custom manager.
|
||||
"""
|
||||
objects = ContentTypeManager()
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
@ -6,7 +6,6 @@ from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
@ -45,9 +44,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
type = models.CharField(
|
||||
verbose_name=_('type'),
|
||||
max_length=50,
|
||||
choices=DataSourceTypeChoices,
|
||||
default=DataSourceTypeChoices.LOCAL
|
||||
max_length=50
|
||||
)
|
||||
source_url = models.CharField(
|
||||
max_length=200,
|
||||
@ -96,8 +93,9 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
def docs_url(self):
|
||||
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||
|
||||
def get_type_color(self):
|
||||
return DataSourceTypeChoices.colors.get(self.type)
|
||||
def get_type_display(self):
|
||||
if backend := registry['data_backends'].get(self.type):
|
||||
return backend.label
|
||||
|
||||
def get_status_color(self):
|
||||
return DataSourceStatusChoices.colors.get(self.status)
|
||||
@ -110,10 +108,6 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
def backend_class(self):
|
||||
return registry['data_backends'].get(self.type)
|
||||
|
||||
@property
|
||||
def is_local(self):
|
||||
return self.type == DataSourceTypeChoices.LOCAL
|
||||
|
||||
@property
|
||||
def ready_for_sync(self):
|
||||
return self.enabled and self.status not in (
|
||||
@ -123,8 +117,14 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate data backend type
|
||||
if self.type and self.type not in registry['data_backends']:
|
||||
raise ValidationError({
|
||||
'type': _("Unknown backend type: {type}".format(type=self.type))
|
||||
})
|
||||
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||
raise ValidationError({
|
||||
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
@ -316,7 +316,7 @@ class DataFile(models.Model):
|
||||
if not self.data:
|
||||
return None
|
||||
try:
|
||||
return bytes(self.data, 'utf-8')
|
||||
return self.data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
@ -367,7 +367,7 @@ class AutoSyncRecord(models.Model):
|
||||
related_name='+'
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
to='contenttypes.ContentType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='+'
|
||||
)
|
||||
@ -377,6 +377,8 @@ class AutoSyncRecord(models.Model):
|
||||
fk_field='object_id'
|
||||
)
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
class Meta:
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
|
@ -44,6 +44,7 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
_netbox_private = True
|
||||
|
||||
class Meta:
|
||||
ordering = ('file_root', 'file_path')
|
||||
|
@ -3,7 +3,7 @@ import uuid
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@ -11,8 +11,8 @@ from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import ContentType
|
||||
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
@ -28,9 +28,8 @@ class Job(models.Model):
|
||||
Tracks the lifecycle of a job which represents a background task (e.g. the execution of a custom script).
|
||||
"""
|
||||
object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
to='contenttypes.ContentType',
|
||||
related_name='jobs',
|
||||
limit_choices_to=FeatureQuery('jobs'),
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField(
|
||||
@ -92,6 +91,11 @@ class Job(models.Model):
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
error = models.TextField(
|
||||
verbose_name=_('error'),
|
||||
editable=False,
|
||||
blank=True
|
||||
)
|
||||
job_id = models.UUIDField(
|
||||
verbose_name=_('job ID'),
|
||||
unique=True
|
||||
@ -118,6 +122,15 @@ class Job(models.Model):
|
||||
def get_status_color(self):
|
||||
return JobStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the assigned object type
|
||||
if self.object_type not in ContentType.objects.with_feature('jobs'):
|
||||
raise ValidationError(
|
||||
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||
)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if not self.completed:
|
||||
@ -158,7 +171,7 @@ class Job(models.Model):
|
||||
# Handle webhooks
|
||||
self.trigger_webhooks(event=EVENT_JOB_START)
|
||||
|
||||
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
|
||||
def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
|
||||
"""
|
||||
Mark the job as completed, optionally specifying a particular termination status.
|
||||
"""
|
||||
@ -168,6 +181,8 @@ class Job(models.Model):
|
||||
|
||||
# Mark the job as completed
|
||||
self.status = status
|
||||
if error:
|
||||
self.error = error
|
||||
self.completed = timezone.now()
|
||||
self.save()
|
||||
|
||||
|
@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
|
20
netbox/core/tables/columns.py
Normal file
20
netbox/core/tables/columns.py
Normal file
@ -0,0 +1,20 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'BackendTypeColumn',
|
||||
)
|
||||
|
||||
|
||||
class BackendTypeColumn(tables.Column):
|
||||
"""
|
||||
Display a data backend type.
|
||||
"""
|
||||
def render(self, value):
|
||||
if backend := registry['data_backends'].get(value):
|
||||
return backend.label
|
||||
return value
|
||||
|
||||
def value(self, value):
|
||||
return value
|
@ -3,6 +3,7 @@ import django_tables2 as tables
|
||||
|
||||
from core.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from .columns import BackendTypeColumn
|
||||
|
||||
__all__ = (
|
||||
'DataFileTable',
|
||||
@ -15,8 +16,8 @@ class DataSourceTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
type = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Type'),
|
||||
type = BackendTypeColumn(
|
||||
verbose_name=_('Type')
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
@ -34,8 +35,8 @@ class DataSourceTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = DataSource
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters', 'created',
|
||||
'last_updated', 'file_count',
|
||||
'pk', 'id', 'name', 'type', 'status', 'enabled', 'source_url', 'description', 'comments', 'parameters',
|
||||
'created', 'last_updated', 'file_count',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'type', 'status', 'enabled', 'description', 'file_count')
|
||||
|
||||
|
@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
@ -47,7 +48,7 @@ class JobTable(NetBoxTable):
|
||||
model = Job
|
||||
fields = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||
'completed', 'user', 'job_id',
|
||||
'completed', 'user', 'error', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
|
@ -2,7 +2,6 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from ..choices import *
|
||||
from ..models import *
|
||||
|
||||
|
||||
@ -26,26 +25,26 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
data_sources = (
|
||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||
)
|
||||
DataSource.objects.bulk_create(data_sources)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Data Source 4',
|
||||
'type': DataSourceTypeChoices.GIT,
|
||||
'type': 'git',
|
||||
'source_url': 'https://example.com/git/source4'
|
||||
},
|
||||
{
|
||||
'name': 'Data Source 5',
|
||||
'type': DataSourceTypeChoices.GIT,
|
||||
'type': 'git',
|
||||
'source_url': 'https://example.com/git/source5'
|
||||
},
|
||||
{
|
||||
'name': 'Data Source 6',
|
||||
'type': DataSourceTypeChoices.GIT,
|
||||
'type': 'git',
|
||||
'source_url': 'https://example.com/git/source6'
|
||||
},
|
||||
]
|
||||
@ -63,7 +62,7 @@ class DataFileTest(
|
||||
def setUpTestData(cls):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type=DataSourceTypeChoices.LOCAL,
|
||||
type='local',
|
||||
source_url='file:///var/tmp/source1/'
|
||||
)
|
||||
|
||||
|
@ -18,21 +18,21 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
data_sources = (
|
||||
DataSource(
|
||||
name='Data Source 1',
|
||||
type=DataSourceTypeChoices.LOCAL,
|
||||
type='local',
|
||||
source_url='file:///var/tmp/source1/',
|
||||
status=DataSourceStatusChoices.NEW,
|
||||
enabled=True
|
||||
),
|
||||
DataSource(
|
||||
name='Data Source 2',
|
||||
type=DataSourceTypeChoices.LOCAL,
|
||||
type='local',
|
||||
source_url='file:///var/tmp/source2/',
|
||||
status=DataSourceStatusChoices.SYNCING,
|
||||
enabled=True
|
||||
),
|
||||
DataSource(
|
||||
name='Data Source 3',
|
||||
type=DataSourceTypeChoices.GIT,
|
||||
type='git',
|
||||
source_url='https://example.com/git/source3',
|
||||
status=DataSourceStatusChoices.COMPLETED,
|
||||
enabled=False
|
||||
@ -45,7 +45,7 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
params = {'type': [DataSourceTypeChoices.LOCAL]}
|
||||
params = {'type': ['local']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
@ -66,9 +66,9 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
data_sources = (
|
||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||
)
|
||||
DataSource.objects.bulk_create(data_sources)
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django.utils import timezone
|
||||
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
from ..choices import *
|
||||
from ..models import *
|
||||
|
||||
|
||||
@ -11,9 +10,9 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
data_sources = (
|
||||
DataSource(name='Data Source 1', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type=DataSourceTypeChoices.LOCAL, source_url='file:///var/tmp/source3/'),
|
||||
DataSource(name='Data Source 1', type='local', source_url='file:///var/tmp/source1/'),
|
||||
DataSource(name='Data Source 2', type='local', source_url='file:///var/tmp/source2/'),
|
||||
DataSource(name='Data Source 3', type='local', source_url='file:///var/tmp/source3/'),
|
||||
)
|
||||
DataSource.objects.bulk_create(data_sources)
|
||||
|
||||
@ -21,7 +20,7 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Data Source X',
|
||||
'type': DataSourceTypeChoices.GIT,
|
||||
'type': 'git',
|
||||
'source_url': 'http:///exmaple/com/foo/bar/',
|
||||
'description': 'Something',
|
||||
'comments': 'Foo bar baz',
|
||||
@ -29,10 +28,10 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
f"name,type,source_url,enabled",
|
||||
f"Data Source 4,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
||||
f"Data Source 5,{DataSourceTypeChoices.LOCAL},file:///var/tmp/source4/,true",
|
||||
f"Data Source 6,{DataSourceTypeChoices.GIT},http:///exmaple/com/foo/bar/,false",
|
||||
"name,type,source_url,enabled",
|
||||
"Data Source 4,local,file:///var/tmp/source4/,true",
|
||||
"Data Source 5,local,file:///var/tmp/source4/,true",
|
||||
"Data Source 6,git,http:///exmaple/com/foo/bar/,false",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@ -60,7 +59,7 @@ class DataFileTestCase(
|
||||
def setUpTestData(cls):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type=DataSourceTypeChoices.LOCAL,
|
||||
type='local',
|
||||
source_url='file:///var/tmp/source1/'
|
||||
)
|
||||
|
||||
|
@ -2,6 +2,7 @@ from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from utilities.utils import count_related
|
||||
@ -99,7 +100,9 @@ class DataFileListView(generic.ObjectListView):
|
||||
filterset = filtersets.DataFileFilterSet
|
||||
filterset_form = forms.DataFileFilterForm
|
||||
table = tables.DataFileTable
|
||||
actions = ('bulk_delete',)
|
||||
actions = {
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(DataFile)
|
||||
@ -127,7 +130,10 @@ class JobListView(generic.ObjectListView):
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
actions = ('export', 'delete', 'bulk_delete')
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
|
||||
class JobView(generic.ObjectView):
|
||||
@ -152,4 +158,9 @@ class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
return self.queryset.first()
|
||||
if config := self.queryset.first():
|
||||
return config
|
||||
# Instantiate a dummy default config if none has been created yet
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
@ -343,9 +343,9 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
|
||||
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
||||
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
|
||||
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||
'front_image', 'rear_image', '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',
|
||||
@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.JSONField()
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
@ -14,16 +12,16 @@ from dcim import filtersets
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
|
||||
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
@ -98,7 +96,7 @@ class PassThroughPortMixin(object):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(NetBoxModelViewSet):
|
||||
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
@ -114,7 +112,7 @@ class RegionViewSet(NetBoxModelViewSet):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
||||
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
SiteGroup.objects.all(),
|
||||
Site,
|
||||
@ -149,7 +147,7 @@ class SiteViewSet(NetBoxModelViewSet):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationViewSet(NetBoxModelViewSet):
|
||||
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
@ -350,7 +348,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||
@ -389,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
|
||||
class DeviceViewSet(
|
||||
SequentialBulkCreatesMixin,
|
||||
ConfigContextQuerySetMixin,
|
||||
ConfigTemplateRenderMixin,
|
||||
RenderConfigMixin,
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
@ -419,23 +417,6 @@ class DeviceViewSet(
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
|
||||
def render_config(self, request, pk):
|
||||
"""
|
||||
Resolve and render the preferred ConfigTemplate for this Device.
|
||||
"""
|
||||
device = self.get_object()
|
||||
configtemplate = device.get_config_template()
|
||||
if not configtemplate:
|
||||
return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Compile context data
|
||||
context_data = device.get_config_context()
|
||||
context_data.update(request.data)
|
||||
context_data.update({'device': device})
|
||||
|
||||
return self.render_configtemplate(request, configtemplate, context_data)
|
||||
|
||||
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||
@ -505,6 +486,10 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
def get_bulk_destroy_queryset(self):
|
||||
# Ensure child interfaces are deleted prior to their parents
|
||||
return self.get_queryset().order_by('device', 'parent', CollateAsChar('_name'))
|
||||
|
||||
|
||||
class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
queryset = FrontPort.objects.prefetch_related(
|
||||
@ -538,7 +523,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
|
@ -80,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
|
||||
WIDTH_23IN = 23
|
||||
|
||||
CHOICES = (
|
||||
(WIDTH_10IN, _('10 inches')),
|
||||
(WIDTH_19IN, _('19 inches')),
|
||||
(WIDTH_21IN, _('21 inches')),
|
||||
(WIDTH_23IN, _('23 inches')),
|
||||
(WIDTH_10IN, _('{n} inches').format(n=10)),
|
||||
(WIDTH_19IN, _('{n} inches').format(n=19)),
|
||||
(WIDTH_21IN, _('{n} inches').format(n=21)),
|
||||
(WIDTH_23IN, _('{n} inches').format(n=23)),
|
||||
)
|
||||
|
||||
|
||||
@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@ -496,7 +497,8 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||
'id', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth', 'subdevice_role',
|
||||
'airflow', 'weight', 'weight_unit',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -817,7 +819,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@ -993,16 +1001,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_device_bays',
|
||||
label=_('Has device bays'),
|
||||
)
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
@ -1069,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
@ -1745,6 +1743,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
unterminated = django_filters.BooleanFilter(
|
||||
method='_unterminated',
|
||||
label=_('Unterminated'),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@ -1812,6 +1814,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
def _unterminated(self, queryset, name, value):
|
||||
if value:
|
||||
terminated_ids = (
|
||||
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
|
||||
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
|
||||
.values("id")
|
||||
)
|
||||
return queryset.exclude(id__in=terminated_ids)
|
||||
else:
|
||||
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
|
||||
terminations__cable_end=CableEndChoices.SIDE_B
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
@ -420,6 +420,11 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Is full depth')
|
||||
)
|
||||
exclude_from_utilization = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Exclude from utilization')
|
||||
)
|
||||
airflow = forms.ChoiceField(
|
||||
label=_('Airflow'),
|
||||
choices=add_blank_choice(DeviceAirflowChoices),
|
||||
@ -445,7 +450,10 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = DeviceType
|
||||
fieldsets = (
|
||||
(_('Device Type'), ('manufacturer', 'default_platform', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
|
||||
(_('Device Type'), (
|
||||
'manufacturer', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization', 'is_full_depth',
|
||||
'airflow', 'description',
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
||||
|
@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
help_texts = {
|
||||
'time_zone': mark_safe(
|
||||
_('Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)')
|
||||
'{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
|
||||
_('Time zone'), _('available options')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@ -333,8 +335,8 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||
'is_full_depth', 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
model = DeviceRole
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@ -547,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
}
|
||||
if 'location' in data:
|
||||
if location := data.get('location'):
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
f"location__{self.fields['location'].to_field_name}": location,
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
|
||||
help_text=mark_safe(
|
||||
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
|
||||
)
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
model = InventoryItemRole
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side a device'),
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side A device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_a_type = CSVContentTypeField(
|
||||
label=_('Side a type'),
|
||||
label=_('Side A type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side A type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
label=_('Side a name'),
|
||||
help_text=_('Side A component name')
|
||||
label=_('Side A name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Termination B
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side b device'),
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side B device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_b_type = CSVContentTypeField(
|
||||
label=_('Side b type'),
|
||||
label=_('Side B type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side B type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
label=_('Side b name'),
|
||||
help_text=_('Side B component name')
|
||||
label=_('Side B name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Cable attributes
|
||||
@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
def _clean_side(self, side):
|
||||
@ -1188,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
@ -116,17 +116,17 @@ class ModuleCommonForm(forms.Form):
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
_("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format(
|
||||
name=template.component_model.__name__,
|
||||
resolved_name=resolved_name
|
||||
_("Cannot adopt {model} {name} as it already belongs to a module").format(
|
||||
model=template.component_model.__name__,
|
||||
name=resolved_name
|
||||
)
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
_("{name} - {resolved_name} already exists").format(
|
||||
name=template.component_model.__name__,
|
||||
resolved_name=resolved_name
|
||||
_("A {model} named {name} already exists").format(
|
||||
model=template.component_model.__name__,
|
||||
name=resolved_name
|
||||
)
|
||||
)
|
||||
|
@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
unterminated = forms.NullBooleanField(
|
||||
label=_('Unterminated'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('PoE'), ('poe_mode', 'poe_type')),
|
||||
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'position')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
@ -302,7 +302,8 @@ class DeviceTypeForm(NetBoxModelForm):
|
||||
fieldsets = (
|
||||
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
|
||||
(_('Chassis'), (
|
||||
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||
'u_height', 'exclude_from_utilization', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow',
|
||||
'weight', 'weight_unit',
|
||||
)),
|
||||
(_('Images'), ('front_image', 'rear_image')),
|
||||
)
|
||||
@ -310,9 +311,9 @@ class DeviceTypeForm(NetBoxModelForm):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
|
||||
'comments', 'tags',
|
||||
'manufacturer', 'model', 'slug', 'default_platform', 'part_number', 'u_height', 'exclude_from_utilization',
|
||||
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
|
||||
'description', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'front_image': ClearableFileInput(attrs={
|
||||
@ -442,7 +443,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||
"number of rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front ports to be created ({frontport_count}) must match the selected number of "
|
||||
"rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
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)
|
||||
update_counts(Device, 'console_port_count', 'consoleports')
|
||||
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||
update_counts(Device, 'power_port_count', 'powerports')
|
||||
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||
update_counts(Device, 'interface_count', 'interfaces')
|
||||
update_counts(Device, 'front_port_count', 'frontports')
|
||||
update_counts(Device, 'rear_port_count', 'rearports')
|
||||
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
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',
|
||||
])
|
||||
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
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)
|
||||
update_counts(VirtualChassis, 'member_count', 'members')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.5 on 2023-10-20 22:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('dcim', '0181_rename_device_role_device_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='exclude_from_utilization',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal file
19
netbox/dcim/migrations/0183_protect_child_interfaces.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.6 on 2023-10-20 11:48
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0182_devicetype_exclude_from_utilization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='dcim.interface'),
|
||||
),
|
||||
]
|
@ -2,7 +2,6 @@ import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
@ -10,17 +9,17 @@ from django.dispatch import Signal
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ContentType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
@ -98,10 +97,10 @@ class Cable(PrimaryModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
@ -180,6 +179,17 @@ class Cable(PrimaryModel):
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||
|
||||
if (a_pks & b_pks):
|
||||
raise ValidationError(
|
||||
_("A and B terminations cannot connect to the same object.")
|
||||
)
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
@ -247,7 +257,7 @@ class CableTermination(ChangeLoggedModel):
|
||||
verbose_name=_('end')
|
||||
)
|
||||
termination_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
to='contenttypes.ContentType',
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
@ -431,6 +441,8 @@ class CablePath(models.Model):
|
||||
)
|
||||
_nodes = PathField()
|
||||
|
||||
_netbox_private = True
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('cable path')
|
||||
verbose_name_plural = _('cable paths')
|
||||
@ -518,9 +530,16 @@ class CablePath(models.Model):
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
if len(set(t.link for t in terminations)) > 1 and (
|
||||
position_stack and len(terminations) != len(position_stack[-1])
|
||||
):
|
||||
is_split = True
|
||||
break
|
||||
|
||||
@ -529,46 +548,68 @@ class CablePath(models.Model):
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
|
||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
||||
link = terminations[0].link
|
||||
if link is None and len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
elif link is None:
|
||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||
links = [termination.link for termination in terminations if termination.link is not None]
|
||||
if len(links) == 0:
|
||||
if len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert type(link) in (Cable, WirelessLink)
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
|
||||
# Step 3: Record the link and update path status if not "connected"
|
||||
path.append([object_to_path_node(link)])
|
||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
if len(not_connected_terminations) > 0:
|
||||
is_complete = False
|
||||
is_split = True
|
||||
|
||||
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
|
||||
cables = []
|
||||
for link in links:
|
||||
if object_to_path_node(link) not in cables:
|
||||
cables.append(object_to_path_node(link))
|
||||
path.append(cables)
|
||||
|
||||
# Step 5: Update the path status if a link is not connected
|
||||
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
|
||||
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
|
||||
is_active = False
|
||||
|
||||
# Step 4: Determine the far-end terminations
|
||||
if isinstance(link, Cable):
|
||||
# Step 6: Determine the far-end terminations
|
||||
if isinstance(links[0], Cable):
|
||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# Terminations must all belong to same end of Cable
|
||||
local_cable_end = local_cable_terminations[0].cable_end
|
||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
||||
remote_cable_terminations = CableTermination.objects.filter(
|
||||
cable=link,
|
||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
||||
)
|
||||
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
|
||||
remote_terminations = [
|
||||
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
|
||||
]
|
||||
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
# Remote Terminations must all be of the same type, otherwise return a split path
|
||||
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Step 7: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
||||
])
|
||||
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
# Step 8: Determine the "next hop" terminations, if applicable
|
||||
if not remote_terminations:
|
||||
break
|
||||
|
||||
@ -577,20 +618,32 @@ class CablePath(models.Model):
|
||||
rear_ports = RearPort.objects.filter(
|
||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||
)
|
||||
if len(rear_ports) > 1:
|
||||
assert all(rp.positions == 1 for rp in rear_ports)
|
||||
elif rear_ports[0].positions > 1:
|
||||
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||
|
||||
terminations = rear_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], RearPort):
|
||||
|
||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
||||
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||
rear_port_position=1
|
||||
)
|
||||
# Obtain the individual front ports based on the termination and all positions
|
||||
elif len(remote_terminations) > 1 and position_stack:
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
@ -632,9 +685,16 @@ class CablePath(models.Model):
|
||||
|
||||
terminations = [circuit_termination]
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
is_complete = True
|
||||
# Check for non-symmetric path
|
||||
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = True
|
||||
elif len(remote_terminations) == 0:
|
||||
is_complete = False
|
||||
else:
|
||||
# Unsupported topology, mark as split and exit
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
return cls(
|
||||
@ -740,3 +800,15 @@ class CablePath(models.Model):
|
||||
return [
|
||||
ct.get_peer_termination() for ct in nodes
|
||||
]
|
||||
|
||||
def get_asymmetric_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
asymmetric_nodes = []
|
||||
for nodes in self.path_objects:
|
||||
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
|
||||
asymmetric_nodes.extend([node for node in nodes if node.link is None])
|
||||
|
||||
return asymmetric_nodes
|
||||
|
@ -1,5 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@ -89,7 +88,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
self._original_device_type = self.__dict__.get('device_type_id')
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
@ -534,14 +533,16 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device_type != self.device_type:
|
||||
raise ValidationError(
|
||||
_("Rear port ({}) must belong to the same device type").format(self.rear_port)
|
||||
_("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
|
||||
)
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError(
|
||||
_("Invalid rear port position ({}); rear port {} has only {} positions").format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
_("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
|
||||
position=self.rear_port_position,
|
||||
name=self.rear_port.name,
|
||||
count=self.rear_port.positions
|
||||
)
|
||||
)
|
||||
|
||||
@ -707,7 +708,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
db_index=True
|
||||
)
|
||||
component_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
to='contenttypes.ContentType',
|
||||
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
|
@ -1,7 +1,6 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@ -86,7 +85,7 @@ class ComponentModel(NetBoxModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
self._original_device = self.__dict__.get('device_id')
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
@ -537,7 +536,7 @@ class BaseInterface(models.Model):
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
on_delete=models.RESTRICT,
|
||||
related_name='child_interfaces',
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -799,9 +798,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
if self.bridge and self.bridge.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
raise ValidationError({
|
||||
'bridge': _("""
|
||||
The selected bridge interface ({bridge}) belongs to a different device
|
||||
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
|
||||
'bridge': _(
|
||||
"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:
|
||||
raise ValidationError({
|
||||
@ -889,10 +888,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': _("""
|
||||
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)
|
||||
'untagged_vlan': _(
|
||||
"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):
|
||||
@ -1067,9 +1066,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": _("""
|
||||
The number of positions cannot be less than the number of mapped front ports
|
||||
({frontport_count})""").format(frontport_count=frontport_count)
|
||||
"positions": _(
|
||||
"The number of positions cannot be less than the number of mapped front ports "
|
||||
"({frontport_count})"
|
||||
).format(frontport_count=frontport_count)
|
||||
})
|
||||
|
||||
|
||||
@ -1180,7 +1180,7 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
|
||||
db_index=True
|
||||
)
|
||||
component_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
to='contenttypes.ContentType',
|
||||
limit_choices_to=MODULAR_COMPONENT_MODELS,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
|
@ -4,6 +4,7 @@ import yaml
|
||||
from functools import cached_property
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@ -105,6 +106,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
default=1.0,
|
||||
verbose_name=_('height (U)')
|
||||
)
|
||||
exclude_from_utilization = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('exclude from utilization'),
|
||||
help_text=_('Exclude from rack utilization calculations.')
|
||||
)
|
||||
is_full_depth = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_('is full depth'),
|
||||
@ -205,11 +211,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
self._original_u_height = self.__dict__.get('u_height')
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
self._original_front_image = self.__dict__.get('front_image')
|
||||
self._original_rear_image = self.__dict__.get('rear_image')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@ -296,8 +302,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
)
|
||||
if d.position not in u_available:
|
||||
raise ValidationError({
|
||||
'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
|
||||
"{}U").format(d, d.rack, self.u_height)
|
||||
'u_height': _(
|
||||
"Device {device} in rack {rack} does not have sufficient space to accommodate a "
|
||||
"height of {height}U"
|
||||
).format(device=d, rack=d.rack, height=self.u_height)
|
||||
})
|
||||
|
||||
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
|
||||
@ -332,10 +340,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
ret = super().save(*args, **kwargs)
|
||||
|
||||
# Delete any previously uploaded image files that are no longer in use
|
||||
if self.front_image != self._original_front_image:
|
||||
self._original_front_image.delete(save=False)
|
||||
if self.rear_image != self._original_rear_image:
|
||||
self._original_rear_image.delete(save=False)
|
||||
if self._original_front_image and self.front_image != self._original_front_image:
|
||||
default_storage.delete(self._original_front_image)
|
||||
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||
default_storage.delete(self._original_rear_image)
|
||||
|
||||
return ret
|
||||
|
||||
@ -914,7 +922,7 @@ class Device(
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.family != 4:
|
||||
raise ValidationError({
|
||||
'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
|
||||
'primary_ip4': _("{ip} is not an IPv4 address.").format(ip=self.primary_ip4)
|
||||
})
|
||||
if self.primary_ip4.assigned_object in vc_interfaces:
|
||||
pass
|
||||
@ -923,13 +931,13 @@ class Device(
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip4': _(
|
||||
"The specified IP address ({primary_ip4}) is not assigned to this device."
|
||||
).format(primary_ip4=self.primary_ip4)
|
||||
"The specified IP address ({ip}) is not assigned to this device."
|
||||
).format(ip=self.primary_ip4)
|
||||
})
|
||||
if self.primary_ip6:
|
||||
if self.primary_ip6.family != 6:
|
||||
raise ValidationError({
|
||||
'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
|
||||
'primary_ip6': _("{ip} is not an IPv6 address.").format(ip=self.primary_ip6)
|
||||
})
|
||||
if self.primary_ip6.assigned_object in vc_interfaces:
|
||||
pass
|
||||
@ -938,8 +946,8 @@ class Device(
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip6': _(
|
||||
"The specified IP address ({primary_ip6}) is not assigned to this device."
|
||||
).format(primary_ip6=self.primary_ip6)
|
||||
"The specified IP address ({ip}) is not assigned to this device."
|
||||
).format(ip=self.primary_ip6)
|
||||
})
|
||||
if self.oob_ip:
|
||||
if self.oob_ip.assigned_object in vc_interfaces:
|
||||
@ -957,17 +965,19 @@ class Device(
|
||||
raise ValidationError({
|
||||
'platform': _(
|
||||
"The assigned platform is limited to {platform_manufacturer} device types, but this device's "
|
||||
"type belongs to {device_type_manufacturer}."
|
||||
"type belongs to {devicetype_manufacturer}."
|
||||
).format(
|
||||
platform_manufacturer=self.platform.manufacturer,
|
||||
device_type_manufacturer=self.device_type.manufacturer
|
||||
devicetype_manufacturer=self.device_type.manufacturer
|
||||
)
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||
raise ValidationError({
|
||||
'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
|
||||
'cluster': _("The assigned cluster belongs to a different site ({site})").format(
|
||||
site=self.cluster.site
|
||||
)
|
||||
})
|
||||
|
||||
# Validate virtual chassis assignment
|
||||
@ -1439,8 +1449,8 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
if primary_ip.family != family:
|
||||
raise ValidationError({
|
||||
f'primary_ip{family}': _(
|
||||
"{primary_ip} is not an IPv{family} address."
|
||||
).format(family=family, primary_ip=primary_ip)
|
||||
"{ip} is not an IPv{family} address."
|
||||
).format(family=family, ip=primary_ip)
|
||||
})
|
||||
device_interfaces = self.device.vc_interfaces(if_master=False)
|
||||
if primary_ip.assigned_object not in device_interfaces:
|
||||
|
@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role.config_template:
|
||||
if self.role and self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
@ -174,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
powerpanel=self.power_panel,
|
||||
powerpanel_site=self.power_panel.site
|
||||
))
|
||||
|
||||
# AC voltage cannot be negative
|
||||
|
@ -357,7 +357,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
|
||||
return [u for u in elevation.values()]
|
||||
|
||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
|
||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
|
||||
"""
|
||||
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
||||
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
||||
@ -366,9 +366,13 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
:param u_height: Minimum number of contiguous free units required
|
||||
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
|
||||
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
|
||||
:param ignore_excluded_devices: Ignore devices that are marked to exclude from utilization calculations
|
||||
"""
|
||||
# Gather all devices which consume U space within the rack
|
||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
||||
if ignore_excluded_devices:
|
||||
devices = devices.exclude(device_type__exclude_from_utilization=True)
|
||||
|
||||
if exclude is not None:
|
||||
devices = devices.exclude(pk__in=exclude)
|
||||
|
||||
@ -453,7 +457,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
# Determine unoccupied units
|
||||
total_units = len(list(self.units))
|
||||
available_units = self.get_available_units(u_height=0.5)
|
||||
available_units = self.get_available_units(u_height=0.5, ignore_excluded_devices=True)
|
||||
|
||||
# Remove reserved units
|
||||
for ru in self.get_reserved_units():
|
||||
@ -558,9 +562,9 @@ class RackReservation(PrimaryModel):
|
||||
invalid_units = [u for u in self.units if u not in self.rack.units]
|
||||
if invalid_units:
|
||||
raise ValidationError({
|
||||
'units': _("Invalid unit(s) for {}U rack: {}").format(
|
||||
self.rack.u_height,
|
||||
', '.join([str(u) for u in invalid_units]),
|
||||
'units': _("Invalid unit(s) for {height}U rack: {unit_list}").format(
|
||||
height=self.rack.u_height,
|
||||
unit_list=', '.join([str(u) for u in invalid_units])
|
||||
),
|
||||
})
|
||||
|
||||
@ -571,8 +575,8 @@ class RackReservation(PrimaryModel):
|
||||
conflicting_units = [u for u in self.units if u in reserved_units]
|
||||
if conflicting_units:
|
||||
raise ValidationError({
|
||||
'units': _('The following units have already been reserved: {}').format(
|
||||
', '.join([str(u) for u in conflicting_units]),
|
||||
'units': _('The following units have already been reserved: {unit_list}').format(
|
||||
unit_list=', '.join([str(u) for u in conflicting_units])
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('type', 'status', 'tenant', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = (
|
||||
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
|
||||
)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'part_number', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
|
||||
('mtu', 2000),
|
||||
('speed', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('part_id', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('site', 'status', 'tenant', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'position', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'model', 'part_number', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('manufacturer', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('power_panel', 'rack', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'location', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
|
||||
('maximum_draw', 2000),
|
||||
('allocated_draw', 2000),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('rack', 'tenant', 'user', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description',)
|
||||
|
||||
|
||||
@register_search
|
||||
@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
|
||||
('label', 200),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('device', 'label', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('parent', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
|
||||
('shipping_address', 2000),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('region', 'group', 'status', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('parent', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('master', 'domain', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('device', 'status', 'identifier', 'description')
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user