diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 744770180..48c14a2da 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 5cf9b72ab..0525659ae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/translation.yaml b/.github/ISSUE_TEMPLATE/translation.yaml new file mode 100644 index 000000000..d07bc399d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation.yaml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9692194..9d580baa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 6019cef5d..a3e66a429 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 3b37aae56..22de146a2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/README.md b/README.md index 54b3e727e..6e50e5687 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
NetBox logo -

The premiere source of truth powering network automation

+

The premier source of truth powering network automation

CI status

diff --git a/base_requirements.txt b/base_requirements.txt index 4b75b1313..b659c9e8d 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -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 diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 9a6e2417a..5e8507798 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -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", diff --git a/docs/administration/error-reporting.md b/docs/administration/error-reporting.md index 162998774..ccc0a84a5 100644 --- a/docs/administration/error-reporting.md +++ b/docs/administration/error-reporting.md @@ -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: diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md index 9ff71758f..1b8263de3 100644 --- a/docs/configuration/data-validation.md +++ b/docs/configuration/data-validation.md @@ -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", + ] +} +``` diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md index e76930208..d90e6eafc 100644 --- a/docs/configuration/default-values.md +++ b/docs/configuration/default-values.md @@ -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': { diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index d1c47e2fb..8c3526dec 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -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 diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index fd410a9d4..f143be139 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -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. diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 1e0d5c31e..e9ff7bd9f 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -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 diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 3811474d2..0b1ed11df 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -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. ![Adding the run action to a permission](../media/admin_ui_run_permission.png) diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 30198117f..79aa82bc9 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -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`. diff --git a/docs/customization/reports.md b/docs/customization/reports.md index 7e3681304..a821c5da7 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -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. ![Adding the run action to a permission](../media/admin_ui_run_permission.png) diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index 41bf6cb31..c845cd5a7 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -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()`. diff --git a/docs/development/internationalization.md b/docs/development/internationalization.md index bdc7cbdaa..bebc97470 100644 --- a/docs/development/internationalization.md +++ b/docs/development/internationalization.md @@ -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):
{% trans "Circuit List" %}
{# 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 %} ``` diff --git a/docs/development/search.md b/docs/development/search.md index 6ccffa7af..1c4eec169 100644 --- a/docs/development/search.md +++ b/docs/development/search.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 6a53403d6..05cd79f23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ ![NetBox](netbox_logo.svg "NetBox logo"){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. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 0713d12e3..4043416a3 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -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: diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 1cd3e1f0a..6ee1c9901 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -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`. diff --git a/docs/installation/index.md b/docs/installation/index.md index da50fa5fa..5affdf247 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -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. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 42b570964..3667dabd5 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -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. diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index dc332da74..0914d0aa6 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -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. diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index bf0c4755a..e68ddb79d 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -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 diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 264fb95ba..d923bdd5d 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -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. diff --git a/docs/plugins/development/data-backends.md b/docs/plugins/development/data-backends.md new file mode 100644 index 000000000..feffa5bed --- /dev/null +++ b/docs/plugins/development/data-backends.md @@ -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 diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index dcbad9d8d..d3f50a0fb 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -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`) | diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md index 3e7762184..8d7580147 100644 --- a/docs/plugins/development/navigation.md +++ b/docs/plugins/development/navigation.md @@ -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 diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md index e3b861f00..e54844cf0 100644 --- a/docs/plugins/development/search.md +++ b/docs/plugins/development/search.md @@ -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 diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index f846139f0..9d57a9603 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -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 diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md index 514006b01..fc571c05e 100644 --- a/docs/reference/conditions.md +++ b/docs/reference/conditions.md @@ -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" } diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index d941fec34..b8d316218 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index cc16434de..3e61f922a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/netbox/account/models.py b/netbox/account/models.py index 5d6575040..bd5879a85 100644 --- a/netbox/account/models.py +++ b/netbox/account/models.py @@ -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' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index f4abda645..5223de339 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -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', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index e28238fea..4dd726803 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -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(), diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 1a9366583..5c416bff9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -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): diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d2217b45b..0c30e3cda 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -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:') + ' 00ff00'), + } class CircuitImportForm(NetBoxModelImportForm): diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 1fb239023..a82ec1726 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -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 diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 8a540032e..0809cb2f4 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -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', ] diff --git a/netbox/circuits/migrations/0043_circuittype_color.py b/netbox/circuits/migrations/0043_circuittype_color.py new file mode 100644 index 000000000..6c4dffeb6 --- /dev/null +++ b/netbox/circuits/migrations/0043_circuittype_color.py @@ -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), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 0322b67c6..4dc775364 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -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]) diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index b80f92d4d..c22b400eb 100644 --- a/netbox/circuits/search.py +++ b/netbox/circuits/search.py @@ -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') diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 6a05983e6..6ae727eca 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -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') diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index 4117a609c..4ae426df5 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -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', ] diff --git a/netbox/core/apps.py b/netbox/core/apps.py index ffcf0b4ea..2d999c57e 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -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): diff --git a/netbox/core/choices.py b/netbox/core/choices.py index 0067dfed8..8d7050414 100644 --- a/netbox/core/choices.py +++ b/netbox/core/choices.py @@ -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' diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index d2dacbbe0..9ff0b4d63 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -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'), diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 62a58086a..410e2e80c 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -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( diff --git a/netbox/core/forms/bulk_edit.py b/netbox/core/forms/bulk_edit.py index a4ecd646f..dcc92c6f0 100644 --- a/netbox/core/forms/bulk_edit.py +++ b/netbox/core/forms/bulk_edit.py @@ -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, diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index f7a6f3595..a567a9fed 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -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( diff --git a/netbox/core/forms/model_forms.py b/netbox/core/forms/model_forms.py index 01d5474c6..e3184acf6 100644 --- a/netbox/core/forms/model_forms.py +++ b/netbox/core/forms/model_forms.py @@ -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): diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index d25981920..32b546b20 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -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) diff --git a/netbox/core/management/commands/clearcache.py b/netbox/core/management/commands/clearcache.py index 22843c490..dd95013af 100644 --- a/netbox/core/management/commands/clearcache.py +++ b/netbox/core/management/commands/clearcache.py @@ -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") diff --git a/netbox/core/management/commands/makemigrations.py b/netbox/core/management/commands/makemigrations.py index 10874418a..ce40bd3cc 100644 --- a/netbox/core/management/commands/makemigrations.py +++ b/netbox/core/management/commands/makemigrations.py @@ -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): diff --git a/netbox/core/management/commands/migrate.py b/netbox/core/management/commands/migrate.py deleted file mode 100644 index 8d5e45a40..000000000 --- a/netbox/core/management/commands/migrate.py +++ /dev/null @@ -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 diff --git a/netbox/core/migrations/0003_job.py b/netbox/core/migrations/0003_job.py index ab6f058ff..f2fe41afb 100644 --- a/netbox/core/migrations/0003_job.py +++ b/netbox/core/migrations/0003_job.py @@ -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={ diff --git a/netbox/core/migrations/0006_datasource_type_remove_choices.py b/netbox/core/migrations/0006_datasource_type_remove_choices.py new file mode 100644 index 000000000..0ad8d8854 --- /dev/null +++ b/netbox/core/migrations/0006_datasource_type_remove_choices.py @@ -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), + ), + ] diff --git a/netbox/core/migrations/0007_job_add_error_field.py b/netbox/core/migrations/0007_job_add_error_field.py new file mode 100644 index 000000000..e2e173bfd --- /dev/null +++ b/netbox/core/migrations/0007_job_add_error_field.py @@ -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), + ), + ] diff --git a/netbox/core/migrations/0008_contenttype_proxy.py b/netbox/core/migrations/0008_contenttype_proxy.py new file mode 100644 index 000000000..ac11d906a --- /dev/null +++ b/netbox/core/migrations/0008_contenttype_proxy.py @@ -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()), + ], + ), + ] diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py index 185622f5f..c93c392d7 100644 --- a/netbox/core/models/__init__.py +++ b/netbox/core/models/__init__.py @@ -1,3 +1,4 @@ +from .contenttypes import * from .data import * from .files import * from .jobs import * diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py new file mode 100644 index 000000000..0731871ec --- /dev/null +++ b/netbox/core/models/contenttypes.py @@ -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 diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 8e372c2eb..cf40c0bd5 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -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( diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index 38d82463e..138527581 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -44,6 +44,7 @@ class ManagedFile(SyncedDataMixin, models.Model): ) objects = RestrictedQuerySet.as_manager() + _netbox_private = True class Meta: ordering = ('file_root', 'file_path') diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 61b0e64fa..5b9b41e53 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -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() diff --git a/netbox/core/search.py b/netbox/core/search.py index e6d3005e6..5ea9db761 100644 --- a/netbox/core/search.py +++ b/netbox/core/search.py @@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'status', 'description') @register_search diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py new file mode 100644 index 000000000..93f1e3901 --- /dev/null +++ b/netbox/core/tables/columns.py @@ -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 diff --git a/netbox/core/tables/data.py b/netbox/core/tables/data.py index 1ecc42369..4059ea9bc 100644 --- a/netbox/core/tables/data.py +++ b/netbox/core/tables/data.py @@ -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') diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 32ca67f7f..ac27224b3 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -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', diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index dc6d6a5ce..cd25761f0 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -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/' ) diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index e1e916f70..2f60c7522 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -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) diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index 4a50a8d05..16d07f376 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -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/' ) diff --git a/netbox/core/views.py b/netbox/core/views.py index c7c593770..d16fa4ece 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -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 + ) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 2f4eb6581..32dcdc5bb 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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 # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f045f1bb4..cd5a297c9 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 1bcf61b20..2ba24e0aa 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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)'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0261998db..ffd3879a8 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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() diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index cacf1f72b..9c64d8a19 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index a8e75e3c2..d63873b59 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm): ) help_texts = { 'time_zone': mark_safe( - _('Time zone (available options)') + '{} ({})'.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. 00ff00)')), + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), } @@ -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. 00ff00)')), + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), } @@ -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:') + ' vdc1,vdc2,vdc3' + ) ) 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. 00ff00)')), + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), } @@ -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. 00ff00)')), + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), } 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}") diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 77543af12..3be4d08e8 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -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 ) ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 43e5f4481..d0d321187 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 93e214598..da3a2bea4 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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'), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index abd7bd6f6..ea842508f 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -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 diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py index a911d7fd7..60857ecb9 100644 --- a/netbox/dcim/migrations/0176_device_component_counters.py +++ b/netbox/dcim/migrations/0176_device_component_counters.py @@ -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): diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py index 66d1460d9..b452ce2d9 100644 --- a/netbox/dcim/migrations/0177_devicetype_component_counters.py +++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py @@ -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): diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py index 7d07a4d9d..99b304b66 100644 --- a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py +++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py @@ -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): diff --git a/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py new file mode 100644 index 000000000..6943387c5 --- /dev/null +++ b/netbox/dcim/migrations/0182_devicetype_exclude_from_utilization.py @@ -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), + ), + ] diff --git a/netbox/dcim/migrations/0183_protect_child_interfaces.py b/netbox/dcim/migrations/0183_protect_child_interfaces.py new file mode 100644 index 000000000..ca695f4bd --- /dev/null +++ b/netbox/dcim/migrations/0183_protect_child_interfaces.py @@ -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'), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index de7ba0eb6..e276ae3e5 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -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 diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index f58d2bbca..fb3d6333e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -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='+', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e18f25e4f..c24ed4d86 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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='+', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 857251caf..07c1c70f6 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 95f6d41fe..9be8dc0a3 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -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 diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 83e5eb23a..a852ea5cd 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -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 diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index ef0dde4da..0d4b844f9 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -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]) ) }) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index f70c729f4..0784cfaf8 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -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') diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 9413726fa..31e090078 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -32,11 +32,18 @@ class Node(Hyperlink): color: Box fill color (RRGGBB format) labels: An iterable of text strings. Each label will render on a new line within the box. radius: Box corner radius, for rounded corners (default: 10) + object: A copy of the object to allow reference when drawing cables to determine which cables are connected to + which terminations. """ - def __init__(self, position, width, url, color, labels, radius=10, **extra): + object = None + + def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra): super(Node, self).__init__(href=url, target='_parent', **extra) + # Save object for reference by cable systems + self.object = object + x, y = position # Add the box @@ -77,7 +84,7 @@ class Connector(Group): labels: Iterable of text labels """ - def __init__(self, start, url, color, labels=[], **extra): + def __init__(self, start, url, color, labels=[], description=[], **extra): super().__init__(class_='connector', **extra) self.start = start @@ -104,6 +111,8 @@ class Connector(Group): text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text = Text(label, insert=text_coords, class_='bold' if not i else []) link.add(text) + if len(description) > 0: + link.set_desc("\n".join(description)) self.add(link) @@ -150,7 +159,10 @@ class CableTraceSVG: labels.append(location_label) elif instance._meta.model_name == 'circuit': labels[0] = f'Circuit {instance}' + labels.append(instance.type) labels.append(instance.provider) + if instance.description: + labels.append(instance.description) elif instance._meta.model_name == 'circuittermination': if instance.xconnect_id: labels.append(f'{instance.xconnect_id}') @@ -170,6 +182,8 @@ class CableTraceSVG: if hasattr(instance, 'role'): # Device return instance.role.color + elif instance._meta.model_name == 'circuit' and instance.type.color: + return instance.type.color else: # Other parent object return 'e0e0e0' @@ -206,7 +220,8 @@ class CableTraceSVG: url=f'{self.base_url}{term.get_absolute_url()}', color=self._get_color(term), labels=self._get_labels(term), - radius=5 + radius=5, + object=term ) nodes_height = max(nodes_height, node.box['height']) nodes.append(node) @@ -238,22 +253,65 @@ class CableTraceSVG: Polyline(points=points, style=f'stroke: #{connector.color}'), )) - def draw_cable(self, cable): - labels = [ - f'Cable {cable}', - cable.get_status_display() - ] - if cable.type: - labels.append(cable.get_type_display()) - if cable.length and cable.length_unit: - labels.append(f'{cable.length} {cable.get_length_unit_display()}') + def draw_cable(self, cable, terminations, cable_count=0): + """ + Draw a single cable. Terminations and cable count are passed for determining position and padding + + :param cable: The cable to draw + :param terminations: List of terminations to build positioning data off of + :param cable_count: Count of all cables on this layer for determining whether to collapse description into a + tooltip. + """ + + # If the cable count is higher than 2, collapse the description into a tooltip + if cable_count > 2: + # Use the cable __str__ function to denote the cable + labels = [f'{cable}'] + + # Include the label and the status description in the tooltip + description = [ + f'Cable {cable}', + cable.get_status_display() + ] + + if cable.type: + # Include the cable type in the tooltip + description.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + description.append(f'{cable.length} {cable.get_length_unit_display()}') + else: + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + description = [] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + # Include the cable length in the tooltip + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + + # If there is only one termination, center on that termination + # Otherwise average the center across the terminations + if len(terminations) == 1: + center = terminations[0].bottom_center[0] + else: + # Get a list of termination centers + termination_centers = [term.bottom_center[0] for term in terminations] + # Average the centers + center = sum(termination_centers) / len(termination_centers) + + # Create the connector connector = Connector( - start=(self.center + OFFSET, self.cursor), + start=(center, self.cursor), color=cable.color or '000000', url=f'{self.base_url}{cable.get_absolute_url()}', - labels=labels + labels=labels, + description=description ) + # Set the cursor position self.cursor += connector.height return connector @@ -334,34 +392,52 @@ class CableTraceSVG: # Connector (a Cable or WirelessLink) if links: - link = links[0] # Remove Cable from list + link_cables = {} + fanin = False + fanout = False - # Cable - if type(link) is Cable: + # Determine if we have fanins or fanouts + if len(near_ends) > len(set(links)): + self.cursor += FANOUT_HEIGHT + fanin = True + if len(far_ends) > len(set(links)): + fanout = True + cursor = self.cursor + for link in links: + # Cable + if type(link) is Cable and not link_cables.get(link.pk): + # Reset cursor + self.cursor = cursor + # Generate a list of terminations connected to this cable + near_end_link_terminations = [term for term in terminations if term.object.cable == link] + # Draw the cable + cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) + # Add cable to the list of cables + link_cables.update({link.pk: cable}) + # Add cable to drawing + self.connectors.append(cable) - # Account for fan-ins height - if len(near_ends) > 1: - self.cursor += FANOUT_HEIGHT + # Draw fan-ins + if len(near_ends) > 1 and fanin: + for term in terminations: + if term.object.cable == link: + self.draw_fanin(term, cable) - cable = self.draw_cable(link) - self.connectors.append(cable) - - # Draw fan-ins - if len(near_ends) > 1: - for term in terminations: - self.draw_fanin(term, cable) - - # WirelessLink - elif type(link) is WirelessLink: - wirelesslink = self.draw_wirelesslink(link) - self.connectors.append(wirelesslink) + # WirelessLink + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) # Far end termination(s) if len(far_ends) > 1: - self.cursor += FANOUT_HEIGHT - terminations = self.draw_terminations(far_ends) - for term in terminations: - self.draw_fanout(term, cable) + if fanout: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): + self.draw_fanout(term, link_cables.get(term.object.cable.pk)) + else: + self.draw_terminations(far_ends) elif far_ends: self.draw_terminations(far_ends) else: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 68c24ca14..b72c37daa 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -64,9 +64,19 @@ def get_interface_state_attribute(record): Get interface enabled state as string to attach to DOM element. """ if record.enabled: - return "enabled" + return 'enabled' else: - return "disabled" + return 'disabled' + + +def get_interface_connected_attribute(record): + """ + Get interface disconnected state as string to attach to DOM element. + """ + if record.mark_connected or record.cable: + return 'connected' + else: + return 'disconnected' # @@ -456,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) + maximum_draw = tables.Column( + verbose_name=_('Maximum draw (W)') + ) + allocated_draw = tables.Column( + verbose_name=_('Allocated draw (W)') + ) tags = columns.TagColumn( url_name='dcim:powerport_list' ) @@ -615,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi verbose_name=_('VRF'), linkify=True ) + inventory_items = tables.ManyToManyColumn( + linkify_item=True, + verbose_name=_('Inventory Items'), + ) tags = columns.TagColumn( url_name='dcim:interface_list' ) @@ -626,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -674,6 +694,7 @@ class DeviceInterfaceTable(InterfaceTable): 'data-name': lambda record: record.name, 'data-enabled': get_interface_state_attribute, 'data-type': lambda record: record.type, + 'data-connected': get_interface_connected_attribute } @@ -871,8 +892,9 @@ class ModuleBayTable(DeviceComponentTable): url_name='dcim:modulebay_list' ) module_status = columns.TemplateColumn( - verbose_name=_('Module Status'), - template_code=MODULEBAY_STATUS + accessor=tables.A('installed_module__status'), + template_code=MODULEBAY_STATUS, + verbose_name=_('Module Status') ) class Meta(DeviceComponentTable.Meta): @@ -921,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable): discovered = columns.BooleanColumn( verbose_name=_('Discovered'), ) + parent = tables.Column( + linkify=True, + verbose_name=_('Parent'), + ) tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) @@ -929,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable): class Meta(NetBoxTable.Meta): model = models.InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 7d8884fc1..fad238c6e 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -98,6 +98,7 @@ class DeviceTypeTable(NetBoxTable): verbose_name=_('U Height'), template_code='{{ value|floatformat }}' ) + exclude_from_utilization = columns.BooleanColumn() weight = columns.TemplateColumn( verbose_name=_('Weight'), template_code=WEIGHT, @@ -142,9 +143,9 @@ class DeviceTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceType fields = ( - 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'weight', 'description', 'comments', 'instance_count', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height', + 'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', + 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index e4735bd57..40a58ad81 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): linkify=True, verbose_name=_('Tenant') ) + site = tables.Column( + accessor='rack__site', + linkify=True, + verbose_name=_('Site'), + ) comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) @@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable): class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( - 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant', - 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', + 'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', + 'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 1ce362963..f36b11033 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,6 +6,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer from utilities.testing import APITestCase, APIViewTestCases, create_test_device @@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_render_config(self): + configtemplate = ConfigTemplate.objects.create( + name='Config Template 1', + template_code='Config for device {{ device.name }}' + ) + + device = Device.objects.first() + device.config_template = configtemplate + device.save() + + self.add_permissions('dcim.add_device') + url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/' + response = self.client.post(url, {}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['content'], f'Config for device {device.name}') + class ModuleTest(APIViewTestCases.APIViewTestCase): model = Module @@ -1607,6 +1624,33 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase }, ] + def test_bulk_delete_child_interfaces(self): + interface1 = Interface.objects.get(name='Interface 1') + device = interface1.device + self.add_permissions('dcim.delete_interface') + + # Create a child interface + child = Interface.objects.create( + device=device, + name='Interface 1A', + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=interface1 + ) + self.assertEqual(device.interfaces.count(), 4) + + # Attempt to delete only the parent interface + url = self._get_detail_url(interface1) + self.client.delete(url, **self.header) + self.assertEqual(device.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = [ + {"id": interface1.pk}, + {"id": child.pk}, + ] + self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted + class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index d25333aed..a827939f7 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -15,6 +15,7 @@ class CablePathTestCase(TestCase): 1XX: Test direct connections between different endpoint types 2XX: Test different cable topologies 3XX: Test responses to changes in existing objects + 4XX: Test to exclude specific cable topologies """ @classmethod def setUpTestData(cls): @@ -33,12 +34,11 @@ class CablePathTestCase(TestCase): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, nodes, **kwargs): + def _get_cablepath(self, nodes, **kwargs): """ - Assert that a CablePath from origin to destination with a specific intermediate path exists. + Return a given cable path :param nodes: Iterable of steps, with each step being either a single node or a list of nodes - :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) :return: The matching CablePath (if any) """ @@ -48,12 +48,29 @@ class CablePathTestCase(TestCase): path.append([object_to_path_node(node) for node in step]) else: path.append([object_to_path_node(step)]) + return CablePath.objects.filter(path=path, **kwargs).first() - cablepath = CablePath.objects.filter(path=path, **kwargs).first() + def assertPathExists(self, nodes, **kwargs): + """ + Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the + first matching CablePath, if found. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath + def assertPathDoesNotExist(self, nodes, **kwargs): + """ + Assert that a specific CablePath does *not* exist. + + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes + """ + cablepath = self._get_cablepath(nodes, **kwargs) + self.assertIsNone(cablepath, msg='Unexpected CablePath found') + def assertPathIsSet(self, origin, cablepath, msg=None): """ Assert that a specific CablePath instance is set as the path on the origin. @@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) + def test_219_interface_to_interface_duplex_via_multiple_rearports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3] + ) + cable1.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3] + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2] + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3) + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + + def test_221_non_symmetric_paths(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2] + [IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/ + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1) + rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + frontport5 = FrontPort.objects.create( + device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1 + ) + frontport6 = FrontPort.objects.create( + device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + cable6 = Cable( + a_terminations=[frontport4], + b_terminations=[frontport5], + label='C6' + ) + cable6.save() + cable7 = Cable( + a_terminations=[rearport5], + b_terminations=[rearport6], + label='C7' + ) + cable7.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1], + label='C1' + ) + cable1.save() + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2 + ), + is_complete=False + ) + # Create cable1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3], + label='C5' + ) + cable5.save() + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6 + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport6], + b_terminations=[interface2], + label='C3' + ) + cable3.save() + self.assertPathExists( + ( + interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7), + (rearport1, rearport5), (frontport1, frontport5), (cable1, cable6) + ), + is_complete=False, + is_split=True + ) + self.assertPathExists( + ( + interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ( + interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5, + cable7, rearport6, frontport6, cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 3) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] @@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase): is_complete=True, is_active=True ) + + def test_401_exclude_midspan_devices(self): + """ + [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2] + [FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] / + """ + device = Device.objects.create( + site=self.site, + device_type=self.device.device_type, + device_role=self.device.device_role, + name='Test mid-span Device' + ) + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2], + label='C2' + ) + cable2.save() + cable4 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4], + label='C4' + ) + cable4.save() + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport3], + label='C1' + ) + with self.assertRaises(AssertionError): + cable1.save() + + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4) + ), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 0) + + # Create cable 3 + cable3 = Cable( + a_terminations=[frontport2, frontport4], + b_terminations=[interface2], + label='C3' + ) + + with self.assertRaises(AssertionError): + cable3.save() + + self.assertPathDoesNotExist( + ( + interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4), + (rearport1, rearport3), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True, + is_active=True + ) + self.assertPathDoesNotExist( + ( + interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), + (rearport2, rearport4), (frontport2, frontport4), cable3, interface2 + ), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 0) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index dd5ff7bc2..8fbef126e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() + # Cable for unterminated test + Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save() + def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): } self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_unterminated(self): + params = {'unterminated': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'unterminated': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = PowerPanel.objects.all() @@ -4702,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): addresses = ( IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'), IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'), + IPAddress(assigned_object=None, address='10.1.1.3/24'), + IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'), + IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'), + IPAddress(assigned_object=None, address='2001:db8::3/64'), ) IPAddress.objects.bulk_create(addresses) vdcs[0].primary_ip4 = addresses[0] + vdcs[0].primary_ip6 = addresses[3] vdcs[0].save() vdcs[1].primary_ip4 = addresses[1] + vdcs[1].primary_ip6 = addresses[4] vdcs[1].save() def test_device(self): @@ -4728,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'has_primary_ip': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2e5ae0d5c..741a615d4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -238,6 +238,40 @@ class RackTestCase(TestCase): # Check that Device1 is now assigned to Site B self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + def test_utilization(self): + site = Site.objects.first() + rack = Rack.objects.first() + + Device( + name='Device 1', + role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack, + position=1 + ).save() + rack.refresh_from_db() + self.assertEqual(rack.get_utilization(), 1 / 42 * 100) + + # create device excluded from utilization calculations + dt = DeviceType.objects.create( + manufacturer=Manufacturer.objects.first(), + model='Device Type 4', + slug='device-type-4', + u_height=1, + exclude_from_utilization=True + ) + Device( + name='Device 2', + role=DeviceRole.objects.first(), + device_type=dt, + site=site, + rack=rack, + position=5 + ).save() + rack.refresh_from_db() + self.assertEqual(rack.get_utilization(), 1 / 42 * 100) + class DeviceTestCase(TestCase): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index aff4a65b5..88e0d44f2 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -17,7 +17,7 @@ from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF from tenancy.models import Tenant -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -2014,6 +2014,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } @@ -2030,6 +2031,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } @@ -2106,6 +2108,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } @@ -2528,6 +2531,36 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) self.assertHttpStatus(response, 200) + def test_bulk_delete_child_interfaces(self): + interface1 = Interface.objects.get(name='Interface 1') + device = interface1.device + self.add_permissions('dcim.delete_interface') + + # Create a child interface + child = Interface.objects.create( + device=device, + name='Interface 1A', + type=InterfaceTypeChoices.TYPE_VIRTUAL, + parent=interface1 + ) + self.assertEqual(device.interfaces.count(), 6) + + # Attempt to delete only the parent interface + data = { + 'confirm': True, + } + self.client.post(self._get_url('delete', interface1), data) + self.assertEqual(device.interfaces.count(), 6) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = { + 'pk': [interface1.pk, child.pk], + 'confirm': True, + '_confirm': True, # Form button + } + self.client.post(self._get_url('bulk_delete'), data) + self.assertEqual(device.interfaces.count(), 4) # Child & parent were both deleted + class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = FrontPort diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4377e9ee8..b13777464 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,4 @@ import traceback -from collections import defaultdict from django.contrib import messages from django.contrib.contenttypes.models import ContentType @@ -20,11 +19,13 @@ from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.tables import InterfaceVLANTable +from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine @@ -46,15 +47,11 @@ CABLE_TERMINATION_TYPES = { class DeviceComponentsView(generic.ObjectChildrenView): - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect') - action_perms = defaultdict(set, **{ - 'add': {'add'}, - 'import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, + actions = { + **DEFAULT_ACTION_PERMISSIONS, 'bulk_rename': {'change'}, 'bulk_disconnect': {'change'}, - }) + } queryset = Device.objects.all() def get_children(self, request, parent): @@ -122,16 +119,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View) if form.is_valid(): with transaction.atomic(): - count = 0 + cable_ids = set() for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): - if obj.cable is None: - continue - obj.cable.delete() - count += 1 + if obj.cable: + cable_ids.add(obj.cable.pk) + count += 1 + for cable in Cable.objects.filter(pk__in=cable_ids): + cable.delete() - messages.success(request, "Disconnected {} {}".format( - count, self.queryset.model._meta.verbose_name_plural + messages.success(request, _("Disconnected {count} {type}").format( + count=count, + type=self.queryset.model._meta.verbose_name_plural )) return redirect(return_url) @@ -1975,7 +1974,10 @@ class DeviceModuleBaysView(DeviceComponentsView): table = tables.DeviceModuleBayTable filterset = filtersets.ModuleBayFilterSet template_name = 'dcim/device/modulebays.html' - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } tab = ViewTab( label=_('Module Bays'), badge=lambda obj: obj.module_bay_count, @@ -1991,7 +1993,10 @@ class DeviceDeviceBaysView(DeviceComponentsView): table = tables.DeviceDeviceBayTable filterset = filtersets.DeviceBayFilterSet template_name = 'dcim/device/devicebays.html' - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } tab = ViewTab( label=_('Device Bays'), badge=lambda obj: obj.device_bay_count, @@ -2003,11 +2008,14 @@ class DeviceDeviceBaysView(DeviceComponentsView): @register_model_view(Device, 'inventory') class DeviceInventoryView(DeviceComponentsView): - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') child_model = InventoryItem table = tables.DeviceInventoryItemTable filterset = filtersets.InventoryItemFilterSet template_name = 'dcim/device/inventory.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } tab = ViewTab( label=_('Inventory Items'), badge=lambda obj: obj.inventory_item_count, @@ -2033,7 +2041,6 @@ class DeviceRenderConfigView(generic.ObjectView): template_name = 'dcim/device/render_config.html' tab = ViewTab( label=_('Render Config'), - permission='extras.view_configtemplate', weight=2100 ) @@ -2185,6 +2192,11 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(ConsolePort) @@ -2248,6 +2260,11 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(ConsoleServerPort) @@ -2311,6 +2328,11 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(PowerPort) @@ -2374,6 +2396,11 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(PowerOutlet) @@ -2437,6 +2464,11 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(Interface) @@ -2530,7 +2562,8 @@ class InterfaceBulkDisconnectView(BulkDisconnectView): class InterfaceBulkDeleteView(generic.BulkDeleteView): - queryset = Interface.objects.all() + # Ensure child interfaces are deleted prior to their parents + queryset = Interface.objects.order_by('device', 'parent', CollateAsChar('_name')) filterset = filtersets.InterfaceFilterSet table = tables.InterfaceTable @@ -2548,6 +2581,11 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(FrontPort) @@ -2611,6 +2649,11 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(RearPort) @@ -2674,6 +2717,11 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(ModuleBay) @@ -2729,6 +2777,11 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(DeviceBay) @@ -2853,6 +2906,11 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable + template_name = 'dcim/component_list.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } @register_model_view(InventoryItem) @@ -2902,6 +2960,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView): template_name = 'dcim/inventoryitem_bulk_delete.html' +@register_model_view(InventoryItem, 'children') +class InventoryItemChildrenView(generic.ObjectChildrenView): + queryset = InventoryItem.objects.all() + child_model = InventoryItem + table = tables.InventoryItemTable + filterset = filtersets.InventoryItemFilterSet + template_name = 'generic/object_children.html' + tab = ViewTab( + label=_('Children'), + badge=lambda obj: obj.child_items.count(), + permission='dcim.view_inventoryitem', + hide_if_empty=True, + weight=5000 + ) + + def get_children(self, request, parent): + return parent.child_items.restrict(request.user, 'view') + + # # Inventory item roles # @@ -3084,7 +3161,12 @@ class CableListView(generic.ObjectListView): filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') + actions = { + 'import': {'add'}, + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + } @register_model_view(Cable) @@ -3178,7 +3260,9 @@ class ConsoleConnectionsListView(generic.ObjectListView): filterset_form = forms.ConsoleConnectionFilterForm table = tables.ConsoleConnectionTable template_name = 'dcim/connections_list.html' - actions = ('export',) + actions = { + 'export': {'view'}, + } def get_extra_context(self, request): return { @@ -3192,7 +3276,9 @@ class PowerConnectionsListView(generic.ObjectListView): filterset_form = forms.PowerConnectionFilterForm table = tables.PowerConnectionTable template_name = 'dcim/connections_list.html' - actions = ('export',) + actions = { + 'export': {'view'}, + } def get_extra_context(self, request): return { @@ -3206,7 +3292,9 @@ class InterfaceConnectionsListView(generic.ObjectListView): filterset_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable template_name = 'dcim/connections_list.html' - actions = ('export',) + actions = { + 'export': {'view'}, + } def get_extra_context(self, request): return { diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index b6be47bbb..1737ff9f8 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -1,10 +1,16 @@ from jinja2.exceptions import TemplateError +from rest_framework.decorators import action +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST +from netbox.api.renderers import TextRenderer from .nested_serializers import NestedConfigTemplateSerializer __all__ = ( 'ConfigContextQuerySetMixin', + 'ConfigTemplateRenderMixin', + 'RenderConfigMixin', ) @@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin: class ConfigTemplateRenderMixin: - + """ + Provides a method to return a rendered ConfigTemplate as REST API data. + """ def render_configtemplate(self, request, configtemplate, context): try: output = configtemplate.render(context=context) @@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin: 'configtemplate': template_serializer.data, 'content': output }) + + +class RenderConfigMixin(ConfigTemplateRenderMixin): + """ + Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned. + """ + @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. + """ + instance = self.get_object() + object_type = instance._meta.model_name + configtemplate = instance.get_config_template() + if not configtemplate: + return Response({ + 'error': f'No config template found for this {object_type}.' + }, status=HTTP_400_BAD_REQUEST) + + # Compile context data + context_data = instance.get_config_context() + context_data.update(request.data) + context_data.update({object_type: instance}) + + return self.render_configtemplate(request, configtemplate, context_data) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 4da5fa629..4e1b47503 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,10 +1,10 @@ from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from core.api.serializers import JobSerializer from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer +from core.models import ContentType from dcim.api.nested_serializers import ( NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, @@ -14,7 +14,6 @@ from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer @@ -64,7 +63,7 @@ __all__ = ( class WebhookSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), many=True ) @@ -85,7 +84,7 @@ class WebhookSerializer(NetBoxModelSerializer): class CustomFieldSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), many=True ) type = ChoiceField(choices=CustomFieldTypeChoices) @@ -96,15 +95,16 @@ class CustomFieldSerializer(ValidatedModelSerializer): filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() choice_set = NestedCustomFieldChoiceSetSerializer(required=False) - ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) + ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False) + ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False) class Meta: model = CustomField fields = [ 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', - 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default', - 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', 'created', - 'last_updated', + 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', + 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set', + 'created', 'last_updated', ] def validate_type(self, value): @@ -151,7 +151,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): class CustomLinkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), many=True ) @@ -170,7 +170,7 @@ class CustomLinkSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail') content_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), many=True ) data_source = NestedDataSourceSerializer( @@ -215,7 +215,7 @@ class SavedFilterSerializer(ValidatedModelSerializer): class BookmarkSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:bookmark-detail') object_type = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('bookmarks').get_query()), + queryset=ContentType.objects.with_feature('bookmarks'), ) object = serializers.SerializerMethodField(read_only=True) user = NestedUserSerializer() @@ -239,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') object_types = ContentTypeField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), many=True, required=False ) @@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer required=False ) data_file = NestedDataFileSerializer( - read_only=True + required=False ) class Meta: @@ -479,7 +479,7 @@ class ReportSerializer(serializers.Serializer): module = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255) description = serializers.CharField(max_length=255, required=False) - test_methods = serializers.ListField(child=serializers.CharField(max_length=255)) + test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True) result = NestedJobSerializer() display = serializers.SerializerMethodField(read_only=True) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 06797891e..f518275e0 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): data = [ {'id': c[0], 'display': c[1]} for c in page ] - return self.get_paginated_response(data) + else: + data = [] + + return self.get_paginated_response(data) # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 1061bf871..fdb951b7d 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -53,18 +53,29 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) -class CustomFieldVisibilityChoices(ChoiceSet): +class CustomFieldUIVisibleChoices(ChoiceSet): - VISIBILITY_READ_WRITE = 'read-write' - VISIBILITY_READ_ONLY = 'read-only' - VISIBILITY_HIDDEN = 'hidden' - VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset' + ALWAYS = 'always' + IF_SET = 'if-set' + HIDDEN = 'hidden' CHOICES = ( - (VISIBILITY_READ_WRITE, _('Read/write')), - (VISIBILITY_READ_ONLY, _('Read-only')), - (VISIBILITY_HIDDEN, _('Hidden')), - (VISIBILITY_HIDDEN_IFUNSET, _('Hidden (if unset)')), + (ALWAYS, _('Always'), 'green'), + (IF_SET, _('If set'), 'yellow'), + (HIDDEN, _('Hidden'), 'gray'), + ) + + +class CustomFieldUIEditableChoices(ChoiceSet): + + YES = 'yes' + NO = 'no' + HIDDEN = 'hidden' + + CHOICES = ( + (YES, _('Yes'), 'green'), + (NO, _('No'), 'red'), + (HIDDEN, _('Hidden'), 'gray'), ) @@ -244,3 +255,39 @@ class ChangeActionChoices(ChoiceSet): (ACTION_UPDATE, _('Update'), 'blue'), (ACTION_DELETE, _('Delete'), 'red'), ) + + +# +# Dashboard widgets +# + +class DashboardWidgetColorChoices(ChoiceSet): + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + BLACK = 'black' + WHITE = 'white' + + CHOICES = ( + (BLUE, _('Blue')), + (INDIGO, _('Indigo')), + (PURPLE, _('Purple')), + (PINK, _('Pink')), + (RED, _('Red')), + (ORANGE, _('Orange')), + (YELLOW, _('Yellow')), + (GREEN, _('Green')), + (TEAL, _('Teal')), + (CYAN, _('Cyan')), + (GRAY, _('Gray')), + (BLACK, _('Black')), + (WHITE, _('White')), + ) diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py index 1e9f15408..ab708228c 100644 --- a/netbox/extras/dashboard/forms.py +++ b/netbox/extras/dashboard/forms.py @@ -2,9 +2,9 @@ from django import forms from django.urls import reverse_lazy from django.utils.translation import gettext as _ +from extras.choices import DashboardWidgetColorChoices from netbox.registry import registry from utilities.forms import BootstrapMixin, add_blank_choice -from utilities.choices import ButtonColorChoices __all__ = ( 'DashboardWidgetAddForm', @@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form): required=False ) color = forms.ChoiceField( - choices=add_blank_choice(ButtonColorChoices), + choices=add_blank_choice(DashboardWidgetColorChoices), required=False, ) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index dcf83bc14..8cfbb4c61 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -7,15 +7,14 @@ import feedparser import requests from django import forms from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.db.models import Q from django.template.loader import render_to_string from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ +from core.models import ContentType from extras.choices import BookmarkOrderingChoices -from extras.utils import FeatureQuery +from utilities.choices import ButtonColorChoices from utilities.forms import BootstrapMixin from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown @@ -33,13 +32,17 @@ __all__ = ( ) -def get_content_type_labels(): +def get_object_type_choices(): return [ (content_type_identifier(ct), content_type_name(ct)) - for ct in ContentType.objects.filter( - FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') | - Q(app_label='extras', model='configcontext') - ).order_by('app_label', 'model') + for ct in ContentType.objects.public().order_by('app_label', 'model') + ] + + +def get_bookmarks_object_type_choices(): + return [ + (content_type_identifier(ct), content_type_name(ct)) + for ct in ContentType.objects.with_feature('bookmarks').order_by('app_label', 'model') ] @@ -115,6 +118,22 @@ class DashboardWidget: def name(self): return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}' + @property + def fg_color(self): + """ + Return the appropriate foreground (text) color for the widget's color. + """ + if self.color in ( + ButtonColorChoices.CYAN, + ButtonColorChoices.GRAY, + ButtonColorChoices.GREY, + ButtonColorChoices.TEAL, + ButtonColorChoices.WHITE, + ButtonColorChoices.YELLOW, + ): + return ButtonColorChoices.BLACK + return ButtonColorChoices.WHITE + @property def form_data(self): return { @@ -146,7 +165,7 @@ class ObjectCountsWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): models = forms.MultipleChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) filters = forms.JSONField( required=False, @@ -195,7 +214,7 @@ class ObjectListWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): model = forms.ChoiceField( - choices=get_content_type_labels + choices=get_object_type_choices ) page_size = forms.IntegerField( required=False, @@ -331,8 +350,7 @@ class BookmarksWidget(DashboardWidget): class ConfigForm(WidgetConfigForm): object_types = forms.MultipleChoiceField( - # TODO: Restrict the choices by FeatureQuery('bookmarks') - choices=get_content_type_labels, + choices=get_bookmarks_object_type_choices, required=False ) order_by = forms.ChoiceField( diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index fec067263..32850bee2 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -87,8 +87,8 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField fields = [ - 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility', - 'weight', 'is_cloneable', 'description', + 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visible', + 'ui_editable', 'weight', 'is_cloneable', 'description', ] def search(self, queryset, name, value): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 821ce7eb2..5da2a5dde 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -48,11 +48,15 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomFieldChoiceSet.objects.all(), required=False ) - ui_visibility = forms.ChoiceField( - label=_("UI visibility"), - choices=add_blank_choice(CustomFieldVisibilityChoices), - required=False, - initial='' + ui_visible = forms.ChoiceField( + label=_("UI visible"), + choices=add_blank_choice(CustomFieldUIVisibleChoices), + required=False + ) + ui_editable = forms.ChoiceField( + label=_("UI editable"), + choices=add_blank_choice(CustomFieldUIEditableChoices), + required=False ) is_cloneable = forms.NullBooleanField( label=_('Is cloneable'), diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 466baa241..181b1f8d3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -1,12 +1,11 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelForm from utilities.forms.fields import ( @@ -29,8 +28,7 @@ __all__ = ( class CustomFieldImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields'), help_text=_("One or more assigned object types") ) type = CSVChoiceField( @@ -40,8 +38,7 @@ class CustomFieldImportForm(CSVModelForm): ) object_type = CSVContentTypeField( label=_('Object type'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.public(), required=False, help_text=_("Object type (for object or multi-object fields)") ) @@ -52,10 +49,17 @@ class CustomFieldImportForm(CSVModelForm): required=False, help_text=_('Choice set (for selection fields)') ) - ui_visibility = CSVChoiceField( - label=_('UI visibility'), - choices=CustomFieldVisibilityChoices, - help_text=_('How the custom field is displayed in the user interface') + ui_visible = CSVChoiceField( + label=_('UI visible'), + choices=CustomFieldUIVisibleChoices, + required=False, + help_text=_('Whether the custom field is displayed in the UI') + ) + ui_editable = CSVChoiceField( + label=_('UI editable'), + choices=CustomFieldUIEditableChoices, + required=False, + help_text=_('Whether the custom field is editable in the UI') ) class Meta: @@ -63,7 +67,7 @@ class CustomFieldImportForm(CSVModelForm): fields = ( 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'search_weight', 'filter_logic', 'default', 'choice_set', 'weight', 'validation_minimum', - 'validation_maximum', 'validation_regex', 'ui_visibility', 'is_cloneable', + 'validation_maximum', 'validation_regex', 'ui_visible', 'ui_editable', 'is_cloneable', ) @@ -89,8 +93,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): class CustomLinkImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links'), + queryset=ContentType.objects.with_feature('custom_links'), help_text=_("One or more assigned object types") ) @@ -105,8 +108,7 @@ class CustomLinkImportForm(CSVModelForm): class ExportTemplateImportForm(CSVModelForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates'), + queryset=ContentType.objects.with_feature('export_templates'), help_text=_("One or more assigned object types") ) @@ -143,8 +145,7 @@ class SavedFilterImportForm(CSVModelForm): class WebhookImportForm(NetBoxModelImportForm): content_types = CSVMultipleContentTypeField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks'), + queryset=ContentType.objects.with_feature('webhooks'), help_text=_("One or more assigned object types") ) @@ -164,7 +165,7 @@ class TagImportForm(CSVModelForm): model = Tag fields = ('name', 'slug', 'color', 'description') help_texts = { - 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), + 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'), } diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 7db84d175..5da3ba1e6 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -1,13 +1,11 @@ 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.models import DataFile, DataSource +from core.models import ContentType, DataFile, DataSource from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice @@ -40,12 +38,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), (_('Attributes'), ( - 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visibility', + 'type', 'content_type_id', 'group_name', 'weight', 'required', 'choice_set_id', 'ui_visible', 'ui_editable', 'is_cloneable', )), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + queryset=ContentType.objects.with_feature('custom_fields'), required=False, label=_('Object type') ) @@ -74,10 +72,15 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Choice set') ) - ui_visibility = forms.ChoiceField( - choices=add_blank_choice(CustomFieldVisibilityChoices), + ui_visible = forms.ChoiceField( + choices=add_blank_choice(CustomFieldUIVisibleChoices), required=False, - label=_('UI visibility') + label=_('UI visible') + ) + ui_editable = forms.ChoiceField( + choices=add_blank_choice(CustomFieldUIEditableChoices), + required=False, + label=_('UI editable') ) is_cloneable = forms.NullBooleanField( label=_('Is cloneable'), @@ -109,7 +112,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm): ) content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()), + queryset=ContentType.objects.with_feature('custom_links'), required=False ) enabled = forms.NullBooleanField( @@ -152,7 +155,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): } ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.with_feature('export_templates'), required=False, label=_('Content types') ) @@ -180,7 +183,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): ) content_type_id = ContentTypeChoiceField( label=_('Content type'), - queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()), + queryset=ContentType.objects.with_feature('image_attachments'), required=False ) name = forms.CharField( @@ -196,7 +199,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): ) content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()), + queryset=ContentType.objects.public(), required=False ) enabled = forms.NullBooleanField( @@ -229,7 +232,7 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): (_('Events'), ('type_create', 'type_update', 'type_delete', 'type_job_start', 'type_job_end')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + queryset=ContentType.objects.with_feature('webhooks'), required=False, label=_('Object type') ) @@ -285,12 +288,12 @@ class WebhookFilterForm(NetBoxModelFilterSetForm): class TagFilterForm(SavedFiltersMixin, FilterForm): model = Tag content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Tagged object type') ) for_object_type_id = ContentTypeChoiceField( - queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + queryset=ContentType.objects.with_feature('tags'), required=False, label=_('Allowed object type') ) diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py index be45f5211..e9fb897c0 100644 --- a/netbox/extras/forms/mixins.py +++ b/netbox/extras/forms/mixins.py @@ -2,13 +2,14 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from extras.choices import CustomFieldVisibilityChoices +from extras.choices import * from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( 'CustomFieldsMixin', 'SavedFiltersMixin', + 'TagsMixin', ) @@ -39,7 +40,7 @@ class CustomFieldsMixin: def _get_custom_fields(self, content_type): return CustomField.objects.filter(content_types=content_type).exclude( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN ) def _get_form_field(self, customfield): @@ -50,9 +51,6 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): - if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) @@ -72,3 +70,19 @@ class SavedFiltersMixin(forms.Form): 'usable': True, } ) + + +class TagsMixin(forms.Form): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + label=_('Tags'), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit tags to those applicable to the object type + content_type = ContentType.objects.get_for_model(self._meta.model) + if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'): + self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index d4e59c170..1a4d45f9a 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -2,15 +2,14 @@ import json from django import forms from django.conf import settings -from django.db.models import Q -from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from core.forms.mixins import SyncedDataMixin +from core.models import ContentType from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * -from extras.utils import FeatureQuery from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup @@ -43,14 +42,11 @@ __all__ = ( class CustomFieldForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_fields'), + queryset=ContentType.objects.with_feature('custom_fields') ) object_type = ContentTypeChoiceField( label=_('Object type'), - queryset=ContentType.objects.all(), - # TODO: Come up with a canonical way to register suitable models - limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']), + queryset=ContentType.objects.public(), required=False, help_text=_("Type of the related object (for object/multi-object fields only)") ) @@ -63,7 +59,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): (_('Custom Field'), ( 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', )), - (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')), + (_('Behavior'), ('search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable')), (_('Values'), ('default', 'choice_set')), (_('Validation'), ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -75,13 +71,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): 'type': _( "The type of data stored in this field. For object/multi-object fields, select the related object " "type below." - ) + ), + 'description': _("This will be displayed as help text for the form field. Markdown is supported.") } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present. + # Disable changing the type of a CustomField as it almost universally causes errors if custom field data + # is already present. if self.instance.pk: self.fields['type'].disabled = True @@ -90,10 +88,10 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): extra_choices = forms.CharField( widget=ChoicesWidget(), required=False, - help_text=_( + help_text=mark_safe(_( 'Enter one choice per line. An optional label may be specified for each choice by appending it with a ' - 'comma (for example, "choice1,First Choice").' - ) + 'comma. Example:' + ) + ' choice1,First Choice') ) class Meta: @@ -114,8 +112,7 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('custom_links') + queryset=ContentType.objects.with_feature('custom_links') ) fieldsets = ( @@ -142,8 +139,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): class ExportTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('export_templates') + queryset=ContentType.objects.with_feature('export_templates') ) template_code = forms.CharField( label=_('Template code'), @@ -210,8 +206,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm): class BookmarkForm(BootstrapMixin, forms.ModelForm): object_type = ContentTypeChoiceField( label=_('Object type'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('bookmarks').get_query() + queryset=ContentType.objects.with_feature('bookmarks') ) class Meta: @@ -222,8 +217,7 @@ class BookmarkForm(BootstrapMixin, forms.ModelForm): class WebhookForm(NetBoxModelForm): content_types = ContentTypeMultipleChoiceField( label=_('Content types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('webhooks') + queryset=ContentType.objects.with_feature('webhooks') ) fieldsets = ( @@ -257,8 +251,7 @@ class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('tags'), + queryset=ContentType.objects.with_feature('tags'), required=False ) @@ -325,7 +318,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm): required=False ) tenant_groups = DynamicModelMultipleChoiceField( - label=_('Tenat groups'), + label=_('Tenant groups'), queryset=TenantGroup.objects.all(), required=False ) @@ -488,7 +481,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe (_('Security'), ('ALLOWED_URL_SCHEMES',)), (_('Banners'), ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), (_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), - (_('Validation'), ('CUSTOM_VALIDATORS',)), + (_('Validation'), ('CUSTOM_VALIDATORS', 'PROTECTION_RULES')), (_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)), (_('Miscellaneous'), ( 'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL', @@ -505,6 +498,7 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), + 'PROTECTION_RULES': forms.Textarea(attrs={'class': 'font-monospace'}), 'comment': forms.Textarea(), } @@ -515,22 +509,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe config = get_config() for param in PARAMS: value = getattr(config, param.name) - is_static = hasattr(settings, param.name) - if value: - help_text = self.fields[param.name].help_text - if help_text: - help_text += '
' # Line break - help_text += _('Current value: {value}').format(value=value) - if is_static: - help_text += _(' (defined statically)') - elif value == param.default: - help_text += _(' (default)') - self.fields[param.name].help_text = help_text + + # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for + # CUSTOM_VALIDATORS, which may reference Python objects.) + try: + json.dumps(value) if type(value) in (tuple, list): - value = ', '.join(value) - self.fields[param.name].initial = value - if is_static: + self.fields[param.name].initial = ', '.join(value) + else: + self.fields[param.name].initial = value + except TypeError: + pass + + # Check whether this parameter is statically configured (e.g. in configuration.py) + if hasattr(settings, param.name): self.fields[param.name].disabled = True + self.fields[param.name].help_text = _( + 'This parameter has been defined statically and cannot be modified.' + ) + continue + + # Set the field's help text + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += _('Current value: {value}').format(value=value or '—') + if value == param.default: + help_text += _(' (default)') + self.fields[param.name].help_text = help_text def save(self, commit=True): instance = super().save(commit=False) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index d9a9f41ae..3cf70281c 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -59,7 +59,7 @@ class Command(BaseCommand): logger.error(f"Exception raised during script execution: {e}") clear_webhooks.send(request) job.data = ScriptOutputSerializer(script).data - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/migrations/0001_squashed.py b/netbox/extras/migrations/0001_squashed.py index 2fdcc07eb..6f1f77e53 100644 --- a/netbox/extras/migrations/0001_squashed.py +++ b/netbox/extras/migrations/0001_squashed.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): ('secret', models.CharField(blank=True, max_length=255)), ('ssl_verification', models.BooleanField(default=True)), ('ca_file_path', models.CharField(blank=True, max_length=4096, null=True)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('webhooks'), related_name='webhooks', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='webhooks', to='contenttypes.ContentType')), ], options={ 'ordering': ('name',), @@ -151,7 +151,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)), - ('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('jobs'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.contenttype')), + ('obj_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_results', 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={ @@ -184,7 +184,7 @@ class Migration(migrations.Migration): ('mime_type', models.CharField(blank=True, max_length=50)), ('file_extension', models.CharField(blank=True, max_length=15)), ('as_attachment', models.BooleanField(default=True)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['content_type', 'name'], @@ -201,7 +201,7 @@ class Migration(migrations.Migration): ('group_name', models.CharField(blank=True, max_length=50)), ('button_class', models.CharField(default='default', max_length=30)), ('new_window', models.BooleanField(default=False)), - ('content_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'ordering': ['group_name', 'weight', 'name'], @@ -223,7 +223,7 @@ class Migration(migrations.Migration): ('validation_maximum', models.PositiveIntegerField(blank=True, null=True)), ('validation_regex', models.CharField(blank=True, max_length=500, validators=[utilities.validators.validate_regex])), ('choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)), - ('content_types', models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuery('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType')), + ('content_types', models.ManyToManyField(related_name='custom_fields', to='contenttypes.ContentType')), ], options={ 'ordering': ['weight', 'name'], diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py index 944ef64b2..8bb760980 100644 --- a/netbox/extras/migrations/0094_tag_object_types.py +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -1,5 +1,4 @@ from django.db import migrations, models -import extras.utils class Migration(migrations.Migration): @@ -13,7 +12,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='tag', name='object_types', - field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'), ), migrations.RenameIndex( model_name='taggeditem', diff --git a/netbox/extras/migrations/0099_cachedvalue_ordering.py b/netbox/extras/migrations/0099_cachedvalue_ordering.py new file mode 100644 index 000000000..242ffd983 --- /dev/null +++ b/netbox/extras/migrations/0099_cachedvalue_ordering.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.6 on 2023-10-30 14:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0098_webhook_custom_field_data_webhook_tags'), + ] + + operations = [ + migrations.AlterModelOptions( + name='cachedvalue', + options={'ordering': ('weight', 'object_type', 'value', 'object_id')}, + ), + ] diff --git a/netbox/extras/migrations/0100_customfield_ui_attrs.py b/netbox/extras/migrations/0100_customfield_ui_attrs.py new file mode 100644 index 000000000..a4a713a86 --- /dev/null +++ b/netbox/extras/migrations/0100_customfield_ui_attrs.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +def update_ui_attrs(apps, schema_editor): + """ + Replicate legacy ui_visibility values to the new ui_visible and ui_editable fields. + """ + CustomField = apps.get_model('extras', 'CustomField') + + CustomField.objects.filter(ui_visibility='read-write').update(ui_visible='always', ui_editable='yes') + CustomField.objects.filter(ui_visibility='read-only').update(ui_visible='always', ui_editable='no') + CustomField.objects.filter(ui_visibility='hidden').update(ui_visible='hidden', ui_editable='hidden') + CustomField.objects.filter(ui_visibility='hidden-ifunset').update(ui_visible='if-set', ui_editable='yes') + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0099_cachedvalue_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='ui_editable', + field=models.CharField(default='yes', max_length=50), + ), + migrations.AddField( + model_name='customfield', + name='ui_visible', + field=models.CharField(default='always', max_length=50), + ), + migrations.RunPython( + code=update_ui_attrs, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='customfield', + name='ui_visibility', + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index ac9c60998..5db0bba57 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,10 +1,11 @@ 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.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from ..querysets import ObjectChangeQuerySet @@ -48,7 +49,7 @@ class ObjectChange(models.Model): choices=ObjectChangeActionChoices ) changed_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+' ) @@ -58,7 +59,7 @@ class ObjectChange(models.Model): fk_field='changed_object_id' ) related_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, related_name='+', blank=True, @@ -104,6 +105,17 @@ class ObjectChange(models.Model): self.user_name ) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.changed_object_type not in ContentType.objects.with_feature('change_logging'): + raise ValidationError( + _("Change logging is not supported for this object type ({type}).").format( + type=self.changed_object_type + ) + ) + def save(self, *args, **kwargs): # Record the user's name and the object's representation as static strings diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 47e8dcd82..425c1386a 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -146,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): # Verify that JSON data is provided as an object if type(self.data) is not dict: raise ValidationError( - {'data': _('JSON data must be in object form. Example: {"foo": 123}')} + {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'} ) def sync_data(self): @@ -202,7 +202,7 @@ class ConfigContextModel(models.Model): # Verify that JSON data is provided as an object if self.local_context_data and type(self.local_context_data) is not dict: raise ValidationError( - {'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')} + {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'} ) @@ -260,12 +260,14 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog _context = dict() # Populate the default template context with NetBox model classes, namespaced by app - # TODO: Devise a canonical mechanism for identifying the models to include (see #13427) - for app, model_names in registry['model_features']['custom_fields'].items(): + for app, model_names in registry['models'].items(): _context.setdefault(app, {}) for model_name in model_names: - model = apps.get_registered_model(app, model_name) - _context[app][model.__name__] = model + try: + model = apps.get_registered_model(app, model_name) + _context[app][model.__name__] = model + except LookupError: + pass # Add the provided context data, if any if context is not None: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index ac68855a0..08190d20f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -5,18 +5,16 @@ from datetime import datetime, date import django_filters from django import forms from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.validators import RegexValidator, ValidationError from django.db import models from django.urls import reverse -from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from extras.choices import * from extras.data import CHOICE_SETS -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from netbox.search import FieldTypes @@ -28,6 +26,7 @@ from utilities.forms.fields import ( from utilities.forms.utils import add_blank_choice from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker from utilities.querysets import RestrictedQuerySet +from utilities.templatetags.builtins.filters import render_markdown from utilities.validators import validate_regex __all__ = ( @@ -59,9 +58,8 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_fields', - limit_choices_to=FeatureQuery('custom_fields'), help_text=_('The object(s) to which this field applies.') ) type = models.CharField( @@ -72,7 +70,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The type of data this custom field holds') ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT, blank=True, null=True, @@ -179,12 +177,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): blank=True, null=True ) - ui_visibility = models.CharField( + ui_visible = models.CharField( max_length=50, - choices=CustomFieldVisibilityChoices, - default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - verbose_name=_('UI visibility'), - help_text=_('Specifies the visibility of custom field in the UI') + choices=CustomFieldUIVisibleChoices, + default=CustomFieldUIVisibleChoices.ALWAYS, + verbose_name=_('UI visible'), + help_text=_('Specifies whether the custom field is displayed in the UI') + ) + ui_editable = models.CharField( + max_length=50, + choices=CustomFieldUIEditableChoices, + default=CustomFieldUIEditableChoices.YES, + verbose_name=_('UI editable'), + help_text=_('Specifies whether the custom field value can be edited in the UI') ) is_cloneable = models.BooleanField( default=False, @@ -197,7 +202,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): clone_fields = ( 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', - 'choice_set', 'ui_visibility', 'is_cloneable', + 'choice_set', 'ui_visible', 'ui_editable', 'is_cloneable', ) class Meta: @@ -219,7 +224,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): super().__init__(*args, **kwargs) # Cache instance's original name so we can check later whether it has changed - self._name = self.name + self._name = self.__dict__.get('name') @property def search_type(self): @@ -231,6 +236,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): return self.choice_set.choices return [] + def get_ui_visible_color(self): + return CustomFieldUIVisibleChoices.colors.get(self.ui_visible) + + def get_ui_editable_color(self): + return CustomFieldUIEditableChoices.colors.get(self.ui_editable) + + def get_choice_label(self, value): + if not hasattr(self, '_choice_map'): + self._choice_map = dict(self.choices) + return self._choice_map.get(value, value) + def populate_initial_data(self, content_types): """ Populate initial custom field data upon either a) the creation of a new CustomField, or @@ -281,8 +297,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): except ValidationError as err: raise ValidationError({ 'default': _( - 'Invalid default value "{default}": {message}' - ).format(default=self.default, message=self.message) + 'Invalid default value "{value}": {error}' + ).format(value=self.default, error=err.message) }) # Minimum/maximum values can be set only for numeric fields @@ -317,14 +333,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): 'choice_set': _("Choices may be set only on selection fields.") }) - # A selection field's default (if any) must be present in its available choices - if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices: - raise ValidationError({ - 'default': _( - "The specified default value ({default}) is not listed as an available choice." - ).format(default=self.default) - }) - # Object fields must define an object_type; other fields must not if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): if not self.object_type: @@ -334,8 +342,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): elif self.object_type: raise ValidationError({ 'object_type': _( - "{type_display} fields may not define an object type.") - .format(type_display=self.get_type_display()) + "{type} fields may not define an object type.") + .format(type=self.get_type_display()) }) def serialize(self, value): @@ -384,7 +392,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. - enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering. + enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ initial = self.default if set_initial else None @@ -506,13 +514,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): field.model = self field.label = str(self) if self.description: - field.help_text = escape(self.description) + field.help_text = render_markdown(self.description) # Annotate read-only fields - if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + if enforce_visibility and self.ui_editable != CustomFieldUIEditableChoices.YES: field.disabled = True prepend = '
' if field.help_text else '' - field.help_text += f'{prepend} ' + _('Field is set to read-only.') + field.help_text += f'{prepend} ' + _('Field is not editable.') return field @@ -650,19 +658,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): # Validate selected choice elif self.type == CustomFieldTypeChoices.TYPE_SELECT: - if value not in [c[0] for c in self.choices]: + if value not in self.choice_set.values: raise ValidationError( - _("Invalid choice ({value}). Available choices are: {choices}").format( - value=value, choices=', '.join(self.choices) + _("Invalid choice ({value}) for choice set {choiceset}.").format( + value=value, + choiceset=self.choice_set ) ) # Validate all selected choices elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - if not set(value).issubset([c[0] for c in self.choices]): + if not set(value).issubset(self.choice_set.values): raise ValidationError( - _("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format( - invalid_choices=', '.join(value), available_choices=', '.join(self.choices)) + _("Invalid choice(s) ({value}) for choice set {choiceset}.").format( + value=value, + choiceset=self.choice_set + ) ) # Validate selected object @@ -747,6 +758,13 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel def choices_count(self): return len(self.choices) + @property + def values(self): + """ + Returns an iterator of the valid choice values. + """ + return (x[0] for x in self.choices) + def clean(self): if not self.base_choices and not self.extra_choices: raise ValidationError(_("Must define base or extra choices.")) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 91940d66e..67b455ab4 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,6 @@ import urllib.parse from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.validators import ValidationError from django.db import models @@ -14,10 +13,11 @@ from django.utils.formats import date_format from django.utils.translation import gettext, gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder +from core.models import ContentType from extras.choices import * from extras.conditions import ConditionSet from extras.constants import * -from extras.utils import FeatureQuery, image_upload +from extras.utils import image_upload from netbox.config import get_config from netbox.models import ChangeLoggedModel from netbox.models.features import ( @@ -45,10 +45,9 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo Each Webhook can be limited to firing only on certain actions or certain object types. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='webhooks', verbose_name=_('object types'), - limit_choices_to=FeatureQuery('webhooks'), help_text=_("The object(s) to which this Webhook applies.") ) name = models.CharField( @@ -235,7 +234,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): code to be rendered with an object as context. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='custom_links', help_text=_('The object type(s) to which this link applies.') ) @@ -331,7 +330,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='export_templates', help_text=_('The object type(s) to which this template applies.') ) @@ -440,7 +439,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): A set of predefined keyword parameters that can be reused to filter for specific objects. """ content_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='saved_filters', help_text=_('The object type(s) to which this filter applies.') ) @@ -520,7 +519,7 @@ class ImageAttachment(ChangeLoggedModel): An uploaded image which is associated with an object. """ content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -560,6 +559,15 @@ class ImageAttachment(ChangeLoggedModel): filename = self.image.name.rsplit('/', 1)[-1] return filename.split('_', 2)[2] + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('image_attachments'): + raise ValidationError( + _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def delete(self, *args, **kwargs): _name = self.image.name @@ -605,7 +613,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. """ assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) assigned_object_id = models.PositiveBigIntegerField() @@ -644,9 +652,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat def clean(self): super().clean() - # Prevent the creation of journal entries on unsupported models - permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query()) - if self.assigned_object_type not in permitted_types: + # Validate the assigned object type + if self.assigned_object_type not in ContentType.objects.with_feature('journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -664,7 +671,7 @@ class Bookmark(models.Model): auto_now_add=True ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.PROTECT ) object_id = models.PositiveBigIntegerField() @@ -695,6 +702,15 @@ class Bookmark(models.Model): return str(self.object) return super().__str__() + def clean(self): + super().clean() + + # Validate the assigned object type + if self.object_type not in ContentType.objects.with_feature('bookmarks'): + raise ValidationError( + _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) + ) + class ConfigRevision(models.Model): """ @@ -723,6 +739,8 @@ class ConfigRevision(models.Model): verbose_name_plural = _('config revisions') def __str__(self): + if not self.pk: + return gettext('Default configuration') if self.is_active: return gettext('Current configuration') return gettext('Config revision #{id}').format(id=self.pk) @@ -733,6 +751,8 @@ class ConfigRevision(models.Model): return super().__getattribute__(item) def get_absolute_url(self): + if not self.pk: + return reverse('core:config') # Default config view return reverse('extras:configrevision', args=[self.pk]) def activate(self): diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index debe4c648..9ba779642 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -1,10 +1,12 @@ import uuid -from django.contrib.contenttypes.models import ContentType from django.db import models from django.utils.translation import gettext_lazy as _ +from netbox.search.utils import get_indexer +from netbox.registry import registry from utilities.fields import RestrictedGenericForeignKey +from utilities.utils import content_type_identifier from ..fields import CachedValueField __all__ = ( @@ -24,7 +26,7 @@ class CachedValue(models.Model): editable=False ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) @@ -49,10 +51,28 @@ class CachedValue(models.Model): default=1000 ) + _netbox_private = True + class Meta: - ordering = ('weight', 'object_type', 'object_id') + ordering = ('weight', 'object_type', 'value', 'object_id') verbose_name = _('cached value') verbose_name_plural = _('cached values') def __str__(self): return f'{self.object_type} {self.object_id}: {self.field}={self.value}' + + @property + def display_attrs(self): + """ + Render any display attributes associated with this search result. + """ + indexer = get_indexer(self.object_type) + attrs = {} + for attr in indexer.display_attrs: + name = self.object._meta.get_field(attr).verbose_name + if value := getattr(self.object, attr): + if display_func := getattr(self.object, f'get_{attr}_display', None): + attrs[name] = display_func() + else: + attrs[name] = value + return attrs diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b0df9e26e..2e848a817 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -2,7 +2,6 @@ import logging from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.db import models, transaction from django.utils.translation import gettext_lazy as _ @@ -71,7 +70,7 @@ class StagedChange(ChangeLoggedModel): choices=ChangeActionChoices ) object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='+' ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index f4ba5ea64..3aba6df60 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,13 +1,10 @@ from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -37,9 +34,8 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', related_name='+', - limit_choices_to=FeatureQuery('tags'), blank=True, help_text=_("The object type(s) to which this this tag can be applied.") ) @@ -75,6 +71,8 @@ class TaggedItem(GenericTaggedItemBase): on_delete=models.CASCADE ) + _netbox_private = True + class Meta: indexes = [models.Index(fields=["content_type", "object_id"])] verbose_name = _('tagged item') diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index f60462f3d..31ea1ce09 100644 --- a/netbox/extras/plugins/__init__.py +++ b/netbox/extras/plugins/__init__.py @@ -1,148 +1,9 @@ -import collections -from importlib import import_module - -from django.apps import AppConfig -from django.core.exceptions import ImproperlyConfigured -from django.utils.module_loading import import_string -from packaging import version - -from netbox.registry import registry -from netbox.search import register_search from .navigation import * from .registration import * from .templates import * from .utils import * - -# Initialize plugin registry -registry['plugins'].update({ - 'graphql_schemas': [], - 'menus': [], - 'menu_items': {}, - 'preferences': {}, - 'template_extensions': collections.defaultdict(list), -}) - -DEFAULT_RESOURCE_PATHS = { - 'search_indexes': 'search.indexes', - 'graphql_schema': 'graphql.schema', - 'menu': 'navigation.menu', - 'menu_items': 'navigation.menu_items', - 'template_extensions': 'template_content.template_extensions', - 'user_preferences': 'preferences.preferences', -} +from netbox.plugins import PluginConfig -# -# Plugin AppConfig class -# - -class PluginConfig(AppConfig): - """ - Subclass of Django's built-in AppConfig class, to be used for NetBox plugins. - """ - # Plugin metadata - author = '' - author_email = '' - description = '' - version = '' - - # Root URL path under /plugins. If not set, the plugin's label will be used. - base_url = None - - # Minimum/maximum compatible versions of NetBox - min_version = None - max_version = None - - # Default configuration parameters - default_settings = {} - - # Mandatory configuration parameters - required_settings = [] - - # Middleware classes provided by the plugin - middleware = [] - - # Django-rq queues dedicated to the plugin - queues = [] - - # Django apps to append to INSTALLED_APPS when plugin requires them. - django_apps = [] - - # Optional plugin resources - search_indexes = None - graphql_schema = None - menu = None - menu_items = None - template_extensions = None - user_preferences = None - - def _load_resource(self, name): - # Import from the configured path, if defined. - if path := getattr(self, name, None): - return import_string(f"{self.__module__}.{path}") - - # Fall back to the resource's default path. Return None if the module has not been provided. - default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}' - default_module, resource_name = default_path.rsplit('.', 1) - try: - module = import_module(default_module) - return getattr(module, resource_name, None) - except ModuleNotFoundError: - pass - - def ready(self): - plugin_name = self.name.rsplit('.', 1)[-1] - - # Register search extensions (if defined) - search_indexes = self._load_resource('search_indexes') or [] - for idx in search_indexes: - register_search(idx) - - # Register template content (if defined) - if template_extensions := self._load_resource('template_extensions'): - register_template_extensions(template_extensions) - - # Register navigation menu and/or menu items (if defined) - if menu := self._load_resource('menu'): - register_menu(menu) - if menu_items := self._load_resource('menu_items'): - register_menu_items(self.verbose_name, menu_items) - - # Register GraphQL schema (if defined) - if graphql_schema := self._load_resource('graphql_schema'): - register_graphql_schema(graphql_schema) - - # Register user preferences (if defined) - if user_preferences := self._load_resource('user_preferences'): - register_user_preferences(plugin_name, user_preferences) - - @classmethod - def validate(cls, user_config, netbox_version): - - # Enforce version constraints - current_version = version.parse(netbox_version) - if cls.min_version is not None: - min_version = version.parse(cls.min_version) - if current_version < min_version: - raise ImproperlyConfigured( - f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}." - ) - if cls.max_version is not None: - max_version = version.parse(cls.max_version) - if current_version > max_version: - raise ImproperlyConfigured( - f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}." - ) - - # Verify required configuration settings - for setting in cls.required_settings: - if setting not in user_config: - raise ImproperlyConfigured( - f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of " - f"configuration.py." - ) - - # Apply default configuration values - for setting, value in cls.default_settings.items(): - if setting not in user_config: - user_config[setting] = value +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/navigation.py b/netbox/extras/plugins/navigation.py index 288a78512..08d1baa54 100644 --- a/netbox/extras/plugins/navigation.py +++ b/netbox/extras/plugins/navigation.py @@ -1,71 +1,7 @@ -from netbox.navigation import MenuGroup -from utilities.choices import ButtonColorChoices -from django.utils.text import slugify +import warnings -__all__ = ( - 'PluginMenu', - 'PluginMenuButton', - 'PluginMenuItem', -) +from netbox.plugins.navigation import * -class PluginMenu: - icon_class = 'mdi mdi-puzzle' - - def __init__(self, label, groups, icon_class=None): - self.label = label - self.groups = [ - MenuGroup(label, items) for label, items in groups - ] - if icon_class is not None: - self.icon_class = icon_class - - @property - def name(self): - return slugify(self.label) - - -class PluginMenuItem: - """ - This class represents a navigation menu item. This constitutes primary link and its text, but also allows for - specifying additional link buttons that appear to the right of the item in the van menu. - - Links are specified as Django reverse URL strings. - Buttons are each specified as a list of PluginMenuButton instances. - """ - permissions = [] - buttons = [] - - def __init__(self, link, link_text, permissions=None, buttons=None): - self.link = link - self.link_text = link_text - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if buttons is not None: - if type(buttons) not in (list, tuple): - raise TypeError("Buttons must be passed as a tuple or list.") - self.buttons = buttons - - -class PluginMenuButton: - """ - This class represents a button within a PluginMenuItem. Note that button colors should come from - ButtonColorChoices. - """ - color = ButtonColorChoices.DEFAULT - permissions = [] - - def __init__(self, link, title, icon_class, color=None, permissions=None): - self.link = link - self.title = title - self.icon_class = icon_class - if permissions is not None: - if type(permissions) not in (list, tuple): - raise TypeError("Permissions must be passed as a tuple or list.") - self.permissions = permissions - if color is not None: - if color not in ButtonColorChoices.values(): - raise ValueError("Button color must be a choice within ButtonColorChoices.") - self.color = color +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/registration.py b/netbox/extras/plugins/registration.py index 5b7e58172..8d2d85573 100644 --- a/netbox/extras/plugins/registration.py +++ b/netbox/extras/plugins/registration.py @@ -1,64 +1,7 @@ -import inspect +import warnings -from netbox.registry import registry -from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem -from .templates import PluginTemplateExtension - -__all__ = ( - 'register_graphql_schema', - 'register_menu', - 'register_menu_items', - 'register_template_extensions', - 'register_user_preferences', -) +from netbox.plugins.registration import * -def register_template_extensions(class_list): - """ - Register a list of PluginTemplateExtension classes - """ - # Validation - for template_extension in class_list: - if not inspect.isclass(template_extension): - raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") - if not issubclass(template_extension, PluginTemplateExtension): - raise TypeError(f"{template_extension} is not a subclass of extras.plugins.PluginTemplateExtension!") - if template_extension.model is None: - raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") - - registry['plugins']['template_extensions'][template_extension.model].append(template_extension) - - -def register_menu(menu): - if not isinstance(menu, PluginMenu): - raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu") - registry['plugins']['menus'].append(menu) - - -def register_menu_items(section_name, class_list): - """ - Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) - """ - # Validation - for menu_link in class_list: - if not isinstance(menu_link, PluginMenuItem): - raise TypeError(f"{menu_link} must be an instance of extras.plugins.PluginMenuItem") - for button in menu_link.buttons: - if not isinstance(button, PluginMenuButton): - raise TypeError(f"{button} must be an instance of extras.plugins.PluginMenuButton") - - registry['plugins']['menu_items'][section_name] = class_list - - -def register_graphql_schema(graphql_schema): - """ - Register a GraphQL schema class for inclusion in NetBox's GraphQL API. - """ - registry['plugins']['graphql_schemas'].append(graphql_schema) - - -def register_user_preferences(plugin_name, preferences): - """ - Register a list of user preferences defined by a plugin. - """ - registry['plugins']['preferences'][plugin_name] = preferences +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/templates.py b/netbox/extras/plugins/templates.py index e9b9a9dca..0e09f33d2 100644 --- a/netbox/extras/plugins/templates.py +++ b/netbox/extras/plugins/templates.py @@ -1,73 +1,7 @@ -from django.template.loader import get_template +import warnings -__all__ = ( - 'PluginTemplateExtension', -) +from netbox.plugins.templates import * -class PluginTemplateExtension: - """ - This class is used to register plugin content to be injected into core NetBox templates. It contains methods - that are overridden by plugin authors to return template content. - - The `model` attribute on the class defines the which model detail page this class renders content for. It - should be set as a string in the form '.'. render() provides the following context data: - - * object - The object being viewed - * request - The current request - * settings - Global NetBox settings - * config - Plugin-specific configuration parameters - """ - model = None - - def __init__(self, context): - self.context = context - - def render(self, template_name, extra_context=None): - """ - Convenience method for rendering the specified Django template using the default context data. An additional - context dictionary may be passed as `extra_context`. - """ - if extra_context is None: - extra_context = {} - elif not isinstance(extra_context, dict): - raise TypeError("extra_context must be a dictionary") - - return get_template(template_name).render({**self.context, **extra_context}) - - def left_page(self): - """ - Content that will be rendered on the left of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def right_page(self): - """ - Content that will be rendered on the right of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def full_width_page(self): - """ - Content that will be rendered within the full width of the detail page view. Content should be returned as an - HTML string. Note that content does not need to be marked as safe because this is automatically handled. - """ - raise NotImplementedError - - def buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError - - def list_buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the list view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index 2f237f56a..8b24e8fd2 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -1,41 +1,7 @@ -from importlib import import_module +import warnings -from django.apps import apps -from django.conf import settings -from django.conf.urls import include -from django.contrib.admin.views.decorators import staff_member_required -from django.urls import path -from django.utils.module_loading import import_string, module_has_submodule +from netbox.plugins.urls import * -from . import views -# Initialize URL base, API, and admin URL patterns for plugins -plugin_patterns = [] -plugin_api_patterns = [ - path('', views.PluginsAPIRootView.as_view(), name='api-root'), - path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list') -] -plugin_admin_patterns = [ - path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list') -] - -# Register base/API URL patterns for each plugin -for plugin_path in settings.PLUGINS: - plugin = import_module(plugin_path) - plugin_name = plugin_path.split('.')[-1] - app = apps.get_app_config(plugin_name) - base_url = getattr(app, 'base_url') or app.label - - # Check if the plugin specifies any base URLs - if module_has_submodule(plugin, 'urls'): - urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") - plugin_patterns.append( - path(f"{base_url}/", include((urlpatterns, app.label))) - ) - - # Check if the plugin specifies any API URLs - if module_has_submodule(plugin, 'api.urls'): - urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") - plugin_api_patterns.append( - path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) - ) +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/utils.py b/netbox/extras/plugins/utils.py index c260f156d..15ae018d1 100644 --- a/netbox/extras/plugins/utils.py +++ b/netbox/extras/plugins/utils.py @@ -1,37 +1,7 @@ -from django.apps import apps -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured +import warnings -__all__ = ( - 'get_installed_plugins', - 'get_plugin_config', -) +from netbox.plugins.utils import * -def get_installed_plugins(): - """ - Return a dictionary mapping the names of installed plugins to their versions. - """ - plugins = {} - for plugin_name in settings.PLUGINS: - plugin_name = plugin_name.rsplit('.', 1)[-1] - plugin_config = apps.get_app_config(plugin_name) - plugins[plugin_name] = getattr(plugin_config, 'version', None) - - return dict(sorted(plugins.items())) - - -def get_plugin_config(plugin_name, parameter, default=None): - """ - Return the value of the specified plugin configuration parameter. - - Args: - plugin_name: The name of the plugin - parameter: The name of the configuration parameter - default: The value to return if the parameter is not defined (default: None) - """ - try: - plugin_config = settings.PLUGINS_CONFIG[plugin_name] - return plugin_config.get(parameter, default) - except KeyError: - raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index 5971f78ef..505742e6b 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -1,89 +1,7 @@ -from collections import OrderedDict +import warnings -from django.apps import apps -from django.conf import settings -from django.shortcuts import render -from django.urls.exceptions import NoReverseMatch -from django.views.generic import View -from drf_spectacular.utils import extend_schema -from rest_framework import permissions -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.views import APIView +from netbox.plugins.views import * -class InstalledPluginsAdminView(View): - """ - Admin view for listing all installed plugins - """ - def get(self, request): - plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS] - return render(request, 'extras/admin/plugins_list.html', { - 'plugins': plugins, - }) - - -@extend_schema(exclude=True) -class InstalledPluginsAPIView(APIView): - """ - API view for listing all installed plugins - """ - permission_classes = [permissions.IsAdminUser] - _ignore_model_permissions = True - schema = None - - def get_view_name(self): - return "Installed Plugins" - - @staticmethod - def _get_plugin_data(plugin_app_config): - return { - 'name': plugin_app_config.verbose_name, - 'package': plugin_app_config.name, - 'author': plugin_app_config.author, - 'author_email': plugin_app_config.author_email, - 'description': plugin_app_config.description, - 'version': plugin_app_config.version - } - - def get(self, request, format=None): - return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS]) - - -@extend_schema(exclude=True) -class PluginsAPIRootView(APIView): - _ignore_model_permissions = True - schema = None - - def get_view_name(self): - return "Plugins" - - @staticmethod - def _get_plugin_entry(plugin, app_config, request, format): - # Check if the plugin specifies any API URLs - api_app_name = f'{app_config.name}-api' - try: - entry = (getattr(app_config, 'base_url', app_config.label), reverse( - f"plugins-api:{api_app_name}:api-root", - request=request, - format=format - )) - except NoReverseMatch: - # The plugin does not include an api-root url - entry = None - - return entry - - def get(self, request, format=None): - - entries = [] - for plugin in settings.PLUGINS: - app_config = apps.get_app_config(plugin) - entry = self._get_plugin_entry(plugin, app_config, request, format) - if entry is not None: - entries.append(entry) - - return Response(OrderedDict(( - ('installed-plugins', reverse('plugins-api:plugins-list', request=request, format=format)), - *entries - ))) +# TODO: Remove in v4.0 +warnings.warn(f"{__name__} is deprecated. Import from netbox.plugins instead.", DeprecationWarning) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 6af81a9d9..c8a13fe15 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) def get_module_and_report(module_name, report_name): module = ReportModule.objects.get(file_path=f'{module_name}.py') - report = module.reports.get(report_name) + report = module.reports.get(report_name)() return module, report @@ -40,8 +40,8 @@ def run_report(job, *args, **kwargs): try: report.run(job) - except Exception: - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + except Exception as e: + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) logging.error(f"Error during execution of report {job.name}") finally: # Schedule the next job if an interval has been set @@ -106,8 +106,6 @@ class Report(object): 'failure': 0, 'log': [], } - if not test_methods: - raise Exception("A report must contain at least one test method.") self.test_methods = test_methods @classproperty @@ -137,6 +135,13 @@ class Report(object): def source(self): return inspect.getsource(self.__class__) + @property + def is_valid(self): + """ + Indicates whether the report can be run. + """ + return bool(self.test_methods) + # # Logging methods # @@ -225,7 +230,7 @@ class Report(object): stacktrace = traceback.format_exc() self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e}
{stacktrace}
") logger.error(f"Exception raised during report execution: {e}") - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) # Perform any post-run tasks self.post_run() diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e93326ddc..df75200e6 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -519,7 +519,7 @@ def run_script(data, request, job, commit=True, **kwargs): logger.error(f"Exception raised during script execution: {e}") script.log_info("Database changes have been reverted due to error.") job.data = ScriptOutputSerializer(script).data - job.terminate(status=JobStatusChoices.STATUS_ERRORED) + job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=str(e)) clear_webhooks.send(request) logger.info(f"Script completed in {job.duration}") diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index d6550309f..8bdaf523c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -2,8 +2,10 @@ import importlib import logging from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal +from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.validators import CustomValidator @@ -178,11 +180,7 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type # Custom validation # -@receiver(post_clean) -def run_custom_validators(sender, instance, **kwargs): - config = get_config() - model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' - validators = config.CUSTOM_VALIDATORS.get(model_name, []) +def run_validators(instance, validators): for validator in validators: @@ -198,6 +196,29 @@ def run_custom_validators(sender, instance, **kwargs): validator(instance) +@receiver(post_clean) +def run_save_validators(sender, instance, **kwargs): + model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' + validators = get_config().CUSTOM_VALIDATORS.get(model_name, []) + + run_validators(instance, validators) + + +@receiver(pre_delete) +def run_delete_validators(sender, instance, **kwargs): + model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' + validators = get_config().PROTECTION_RULES.get(model_name, []) + + try: + run_validators(instance, validators) + except ValidationError as e: + raise AbortRequest( + _("Deletion is prevented by a protection rule: {message}").format( + message=e + ) + ) + + # # Dynamic configuration # diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 9e14a2d27..54194c00f 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -71,8 +71,11 @@ class CustomFieldTable(NetBoxTable): required = columns.BooleanColumn( verbose_name=_('Required') ) - ui_visibility = columns.ChoiceFieldColumn( - verbose_name=_('UI Visibility') + ui_visible = columns.ChoiceFieldColumn( + verbose_name=_('Visible') + ) + ui_editable = columns.ChoiceFieldColumn( + verbose_name=_('Editable') ) description = columns.MarkdownColumn( verbose_name=_('Description') @@ -94,8 +97,8 @@ class CustomFieldTable(NetBoxTable): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', - 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices', - 'created', 'last_updated', + 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'is_cloneable', 'weight', 'choice_set', + 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 019aef235..7ac6b2035 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -427,6 +428,97 @@ class CustomFieldTest(TestCase): self.assertNotIn('field1', site.custom_field_data) self.assertEqual(site.custom_field_data['field2'], FIELD_DATA) + def test_default_value_validation(self): + choiceset = CustomFieldChoiceSet.objects.create( + name="Test Choice Set", + extra_choices=( + ('choice1', 'Choice 1'), + ('choice2', 'Choice 2'), + ) + ) + site = Site.objects.create(name='Site 1', slug='site-1') + object_type = ContentType.objects.get_for_model(Site) + + # Text + CustomField(name='test', type='text', required=True, default="Default text").full_clean() + + # Integer + CustomField(name='test', type='integer', required=True, default=1).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='integer', required=True, default='xxx').full_clean() + + # Boolean + CustomField(name='test', type='boolean', required=True, default=True).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='boolean', required=True, default='xxx').full_clean() + + # Date + CustomField(name='test', type='date', required=True, default="2023-02-25").full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='date', required=True, default='xxx').full_clean() + + # Datetime + CustomField(name='test', type='datetime', required=True, default="2023-02-25 02:02:02").full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='datetime', required=True, default='xxx').full_clean() + + # URL + CustomField(name='test', type='url', required=True, default="https://www.netbox.dev").full_clean() + + # JSON + CustomField(name='test', type='json', required=True, default='{"test": "object"}').full_clean() + + # Selection + CustomField(name='test', type='select', required=True, choice_set=choiceset, default='choice1').full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='select', required=True, choice_set=choiceset, default='xxx').full_clean() + + # Multi-select + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['choice1'] # Single default choice + ).full_clean() + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['choice1', 'choice2'] # Multiple default choices + ).full_clean() + with self.assertRaises(ValidationError): + CustomField( + name='test', + type='multiselect', + required=True, + choice_set=choiceset, + default=['xxx'] + ).full_clean() + + # Object + CustomField(name='test', type='object', required=True, object_type=object_type, default=site.pk).full_clean() + with self.assertRaises(ValidationError): + CustomField(name='test', type='object', required=True, object_type=object_type, default="xxx").full_clean() + + # Multi-object + CustomField( + name='test', + type='multiobject', + required=True, + object_type=object_type, + default=[site.pk] + ).full_clean() + with self.assertRaises(ValidationError): + CustomField( + name='test', + type='multiobject', + required=True, + object_type=object_type, + default=["xxx"] + ).full_clean() + class CustomFieldManagerTest(TestCase): @@ -1085,7 +1177,11 @@ class CustomFieldImportTest(TestCase): ) csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'}) + response = self.client.post(reverse('dcim:site_import'), { + 'data': csv_data, + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, + }) self.assertEqual(response.status_code, 302) self.assertEqual(Site.objects.count(), 3) diff --git a/netbox/extras/tests/test_customvalidator.py b/netbox/extras/tests/test_customvalidation.py similarity index 64% rename from netbox/extras/tests/test_customvalidator.py rename to netbox/extras/tests/test_customvalidation.py index 0fe507b67..d74ad599b 100644 --- a/netbox/extras/tests/test_customvalidator.py +++ b/netbox/extras/tests/test_customvalidation.py @@ -1,10 +1,13 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.db import transaction from django.test import TestCase, override_settings from ipam.models import ASN, RIR +from dcim.choices import SiteStatusChoices from dcim.models import Site from extras.validators import CustomValidator +from utilities.exceptions import AbortRequest class MyValidator(CustomValidator): @@ -14,6 +17,20 @@ class MyValidator(CustomValidator): self.fail("Name must be foo!") +eq_validator = CustomValidator({ + 'asn': { + 'eq': 100 + } +}) + + +neq_validator = CustomValidator({ + 'asn': { + 'neq': 100 + } +}) + + min_validator = CustomValidator({ 'asn': { 'min': 65000 @@ -77,6 +94,18 @@ class CustomValidatorTest(TestCase): validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0] self.assertIsInstance(validator, CustomValidator) + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [eq_validator]}) + def test_eq(self): + ASN(asn=100, rir=RIR.objects.first()).clean() + with self.assertRaises(ValidationError): + ASN(asn=99, rir=RIR.objects.first()).clean() + + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [neq_validator]}) + def test_neq(self): + ASN(asn=99, rir=RIR.objects.first()).clean() + with self.assertRaises(ValidationError): + ASN(asn=100, rir=RIR.objects.first()).clean() + @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]}) def test_min(self): with self.assertRaises(ValidationError): @@ -147,7 +176,7 @@ class CustomValidatorConfigTest(TestCase): @override_settings( CUSTOM_VALIDATORS={ 'dcim.site': ( - 'extras.tests.test_customvalidator.MyValidator', + 'extras.tests.test_customvalidation.MyValidator', ) } ) @@ -159,3 +188,62 @@ class CustomValidatorConfigTest(TestCase): Site(name='foo', slug='foo').clean() with self.assertRaises(ValidationError): Site(name='bar', slug='bar').clean() + + +class ProtectionRulesConfigTest(TestCase): + + @override_settings( + PROTECTION_RULES={ + 'dcim.site': [ + {'status': {'eq': SiteStatusChoices.STATUS_DECOMMISSIONING}} + ] + } + ) + def test_plain_data(self): + """ + Test custom validator configuration using plain data (as opposed to a CustomValidator + class) + """ + # Create a site with a protected status + site = Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE) + site.save() + + # Try to delete it + with self.assertRaises(AbortRequest): + with transaction.atomic(): + site.delete() + + # Change its status to an allowed value + site.status = SiteStatusChoices.STATUS_DECOMMISSIONING + site.save() + + # Deletion should now succeed + site.delete() + + @override_settings( + PROTECTION_RULES={ + 'dcim.site': ( + 'extras.tests.test_customvalidation.MyValidator', + ) + } + ) + def test_dotted_path(self): + """ + Test custom validator configuration using a dotted path (string) reference to a + CustomValidator class. + """ + # Create a site with a protected name + site = Site(name='bar', slug='bar') + site.save() + + # Try to delete it + with self.assertRaises(AbortRequest): + with transaction.atomic(): + site.delete() + + # Change the name to an allowed value + site.name = site.slug = 'foo' + site.save() + + # Deletion should now succeed + site.delete() diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 69111e6a7..c5a6706c0 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -40,7 +40,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=True, weight=100, filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ui_visible=CustomFieldUIVisibleChoices.ALWAYS, + ui_editable=CustomFieldUIEditableChoices.YES ), CustomField( name='Custom Field 2', @@ -48,7 +49,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=200, filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY + ui_visible=CustomFieldUIVisibleChoices.IF_SET, + ui_editable=CustomFieldUIEditableChoices.NO ), CustomField( name='Custom Field 3', @@ -56,7 +58,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=300, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN ), CustomField( name='Custom Field 4', @@ -64,7 +67,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=400, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[0] ), CustomField( @@ -73,7 +77,8 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): required=False, weight=500, filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN, + ui_visible=CustomFieldUIVisibleChoices.HIDDEN, + ui_editable=CustomFieldUIEditableChoices.HIDDEN, choice_set=choice_sets[1] ), ) @@ -106,8 +111,12 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_ui_visibility(self): - params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} + def test_ui_visible(self): + params = {'ui_visible': CustomFieldUIVisibleChoices.ALWAYS} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_ui_editable(self): + params = {'ui_editable': CustomFieldUIEditableChoices.YES} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_choice_set(self): diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 01ef9a2a6..3d4b3e9a9 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from dcim.models import Site +from dcim.models import DeviceType, Manufacturer, Site from extras.choices import * from extras.models import * from utilities.testing import ViewTestCases, TestCase @@ -50,15 +50,16 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, - 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + 'ui_visible': CustomFieldUIVisibleChoices.ALWAYS, + 'ui_editable': CustomFieldUIEditableChoices.YES, } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility', - 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write', - 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write', - 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write', - 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write', + 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visible,ui_editable', + 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},always,yes', + 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,always,yes', + 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,always,yes', + 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,always,yes', ) cls.csv_update_data = ( @@ -434,7 +435,8 @@ class ConfigContextTestCase( @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') # Create three ConfigContexts for i in range(1, 4): @@ -443,7 +445,7 @@ class ConfigContextTestCase( data={'foo': i} ) configcontext.save() - configcontext.sites.add(site) + configcontext.device_types.add(devicetype) cls.form_data = { 'name': 'Config Context X', @@ -451,11 +453,12 @@ class ConfigContextTestCase( 'description': 'A new config context', 'is_active': True, 'regions': [], - 'sites': [site.pk], + 'sites': [], 'roles': [], 'platforms': [], 'tenant_groups': [], 'tenants': [], + 'device_types': [devicetype.id], 'tags': [], 'data': '{"foo": 123}', } diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 23892e098..c6b2de188 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -from django.db.models import Q -from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager from netbox.registry import registry @@ -31,29 +29,6 @@ def image_upload(instance, filename): return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) -@deconstructible -class FeatureQuery: - """ - Helper class that delays evaluation of the registry contents for the functionality store - until it has been populated. - """ - def __init__(self, feature): - self.feature = feature - - def __call__(self): - return self.get_query() - - def get_query(self): - """ - Given an extras feature, return a Q object for content type lookup - """ - query = Q() - for app_label, models in registry['model_features'][self.feature].items(): - query |= Q(app_label=app_label, model__in=models) - - return query - - def register_features(model, features): """ Register model features in the application registry. @@ -67,6 +42,10 @@ def register_features(model, features): f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}" ) + # Register public models + if not getattr(model, '_netbox_private', False): + registry['models'][app_label].add(model_name) + def is_script(obj): """ diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 686c9b032..98b4fd88d 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,15 +1,38 @@ -from django.core.exceptions import ValidationError from django.core import validators +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ # NOTE: As this module may be imported by configuration.py, we cannot import # anything from NetBox itself. +class IsEqualValidator(validators.BaseValidator): + """ + Employed by CustomValidator to require a specific value. + """ + message = _("Ensure this value is equal to %(limit_value)s.") + code = "is_equal" + + def compare(self, a, b): + return a != b + + +class IsNotEqualValidator(validators.BaseValidator): + """ + Employed by CustomValidator to exclude a specific value. + """ + message = _("Ensure this value does not equal %(limit_value)s.") + code = "is_not_equal" + + def compare(self, a, b): + return a == b + + class IsEmptyValidator: """ Employed by CustomValidator to enforce required fields. """ - message = "This field must be empty." + message = _("This field must be empty.") code = 'is_empty' def __init__(self, enforce=True): @@ -24,7 +47,7 @@ class IsNotEmptyValidator: """ Employed by CustomValidator to enforce prohibited fields. """ - message = "This field must not be empty." + message = _("This field must not be empty.") code = 'not_empty' def __init__(self, enforce=True): @@ -50,6 +73,8 @@ class CustomValidator: :param validation_rules: A dictionary mapping object attributes to validation rules """ VALIDATORS = { + 'eq': IsEqualValidator, + 'neq': IsNotEqualValidator, 'min': validators.MinValueValidator, 'max': validators.MaxValueValidator, 'min_length': validators.MinLengthValidator, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9efcc02dc..0e8e3b0ea 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -16,6 +16,7 @@ from core.tables import JobTable from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class from netbox.config import get_config, PARAMS +from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx @@ -210,7 +211,10 @@ class ExportTemplateListView(generic.ObjectListView): filterset_form = forms.ExportTemplateFilterForm table = tables.ExportTemplateTable template_name = 'extras/exporttemplate_list.html' - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_sync': {'sync'}, + } @register_model_view(ExportTemplate) @@ -472,7 +476,12 @@ class ConfigContextListView(generic.ObjectListView): filterset_form = forms.ConfigContextFilterForm table = tables.ConfigContextTable template_name = 'extras/configcontext_list.html' - actions = ('add', 'bulk_edit', 'bulk_delete', 'bulk_sync') + actions = { + 'add': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + 'bulk_sync': {'sync'}, + } @register_model_view(ConfigContext) @@ -576,7 +585,10 @@ class ConfigTemplateListView(generic.ObjectListView): filterset_form = forms.ConfigTemplateFilterForm table = tables.ConfigTemplateTable template_name = 'extras/configtemplate_list.html' - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync') + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_sync': {'sync'}, + } @register_model_view(ConfigTemplate) @@ -627,7 +639,9 @@ class ObjectChangeListView(generic.ObjectListView): filterset_form = forms.ObjectChangeFilterForm table = tables.ObjectChangeTable template_name = 'extras/objectchange_list.html' - actions = ('export',) + actions = { + 'export': {'view'}, + } @register_model_view(ObjectChange) @@ -693,7 +707,9 @@ class ImageAttachmentListView(generic.ObjectListView): filterset = filtersets.ImageAttachmentFilterSet filterset_form = forms.ImageAttachmentFilterForm table = tables.ImageAttachmentTable - actions = ('export',) + actions = { + 'export': {'view'}, + } @register_model_view(ImageAttachment, 'edit') @@ -736,7 +752,12 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm table = tables.JournalEntryTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') + actions = { + 'import': {'add'}, + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + } @register_model_view(JournalEntry) @@ -978,6 +999,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): }) +def get_report_module(module, request): + return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ReportView(ContentTypePermissionRequiredMixin, View): """ Display a single Report and its associated Job (if any). @@ -986,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1007,7 +1032,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_report'): return HttpResponseForbidden() - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled) @@ -1046,7 +1071,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() return render(request, 'extras/report/source.html', { @@ -1062,7 +1087,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_report' def get(self, request, module, name): - module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module) + module = get_report_module(module, request) report = module.reports[name]() object_type = ContentType.objects.get(app_label='extras', model='reportmodule') @@ -1151,13 +1176,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): }) +def get_script_module(module, request): + return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.") + + class ScriptView(ContentTypePermissionRequiredMixin, View): def get_required_permission(self): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(initial=normalize_querydict(request.GET)) @@ -1181,7 +1210,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View): if not request.user.has_perm('extras.run_script'): return HttpResponseForbidden() - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() form = script.as_form(request.POST, request.FILES) @@ -1218,7 +1247,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() return render(request, 'extras/script/source.html', { @@ -1234,7 +1263,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) + module = get_script_module(module, request) script = module.scripts[name]() object_type = ContentType.objects.get(app_label='extras', model='scriptmodule') diff --git a/netbox/ipam/api/field_serializers.py b/netbox/ipam/api/field_serializers.py index d44d8b7d4..d12530a60 100644 --- a/netbox/ipam/api/field_serializers.py +++ b/netbox/ipam/api/field_serializers.py @@ -1,21 +1,18 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from ipam import models from netaddr import AddrFormatError, IPNetwork -__all__ = [ +__all__ = ( 'IPAddressField', -] + 'IPNetworkField', +) -# -# IP address field -# - class IPAddressField(serializers.CharField): - """IPAddressField with mask""" - + """ + An IPv4 or IPv6 address with optional mask + """ default_error_messages = { 'invalid': _('Enter a valid IPv4 or IPv6 address with optional mask.'), } @@ -24,7 +21,27 @@ class IPAddressField(serializers.CharField): try: return IPNetwork(data) except AddrFormatError: - raise serializers.ValidationError("Invalid IP address format: {}".format(data)) + raise serializers.ValidationError(_("Invalid IP address format: {data}").format(data)) + except (TypeError, ValueError) as e: + raise serializers.ValidationError(e) + + def to_representation(self, value): + return str(value) + + +class IPNetworkField(serializers.CharField): + """ + An IPv4 or IPv6 prefix + """ + default_error_messages = { + 'invalid': _('Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation.'), + } + + def to_internal_value(self, data): + try: + return IPNetwork(data) + except AddrFormatError: + raise serializers.ValidationError(_("Invalid IP prefix format: {data}").format(data)) except (TypeError, ValueError) as e: raise serializers.ValidationError(e) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 93a5d36eb..c46949bb1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -13,7 +13,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from .nested_serializers import * -from .field_serializers import IPAddressField +from .field_serializers import IPAddressField, IPNetworkField # @@ -138,7 +138,7 @@ class AggregateSerializer(NetBoxModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) - prefix = serializers.CharField() + prefix = IPNetworkField() class Meta: model = Aggregate @@ -146,7 +146,6 @@ class AggregateSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family'] # @@ -306,7 +305,7 @@ class PrefixSerializer(NetBoxModelSerializer): role = NestedRoleSerializer(required=False, allow_null=True) children = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(read_only=True) - prefix = serializers.CharField() + prefix = IPNetworkField() class Meta: model = Prefix @@ -315,7 +314,6 @@ class PrefixSerializer(NetBoxModelSerializer): 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children', '_depth', ] - read_only_fields = ['family'] class PrefixLengthSerializer(serializers.Serializer): @@ -386,7 +384,6 @@ class IPRangeSerializer(NetBoxModelSerializer): 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family'] # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index da6463e23..662b393de 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -266,6 +266,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): # Normalize request data to a list of objects requested_objects = request.data if isinstance(request.data, list) else [request.data] + limit = len(requested_objects) # Serialize and validate the request data serializer = self.write_serializer_class(data=requested_objects, many=True, context={ @@ -279,7 +280,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): - available_objects = self.get_available_objects(parent) + available_objects = self.get_available_objects(parent, limit) # Determine if the requested number of objects is available if not self.check_sufficient_available(serializer.validated_data, available_objects): @@ -289,7 +290,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView): ) # Prepare object data for deserialization - requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) + requested_objects = self.prep_object_data(requested_objects, available_objects, parent) # Initialize the serializer with a list or a single object depending on what was requested serializer_class = get_serializer_for_model(self.queryset.model) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index c461de3bd..c296774b9 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -29,6 +29,7 @@ __all__ = ( 'L2VPNFilterSet', 'L2VPNTerminationFilterSet', 'PrefixFilterSet', + 'PrimaryIPFilterSet', 'RIRFilterSet', 'RoleFilterSet', 'RouteTargetFilterSet', @@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): ) mask_length = MultiValueNumberFilter( field_name='prefix', - lookup_expr='net_mask_length' + lookup_expr='net_mask_length', + label=_('Mask length') ) mask_length__gte = django_filters.NumberFilter( field_name='prefix', @@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): method='filter_address', label=_('Address'), ) - mask_length = django_filters.NumberFilter( - method='filter_mask_length', - label=_('Mask length'), + mask_length = MultiValueNumberFilter( + field_name='address', + lookup_expr='net_mask_length', + label=_('Mask length') + ) + mask_length__gte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__gte' + ) + mask_length__lte = django_filters.NumberFilter( + field_name='address', + lookup_expr='net_mask_length__lte' ) vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), @@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): except ValidationError: return queryset.none() - def filter_mask_length(self, queryset, name, value): - if not value: - return queryset - return queryset.filter(address__net_mask_length=value) - @extend_schema_field(OpenApiTypes.STR) def filter_present_in_vrf(self, queryset, name, vrf): if vrf is None: @@ -1236,3 +1242,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet): ) return qs + + +class PrimaryIPFilterSet(django_filters.FilterSet): + """ + An inheritable FilterSet for models which support primary IP assignment. + """ + 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)'), + ) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 548d01afa..f0a8286fc 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,7 +1,8 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from dcim.models import Region, Site, SiteGroup +from dcim.models import Location, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -10,9 +11,10 @@ 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, NumericArrayField, + CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, ) from utilities.forms.widgets import BulkEditNullBooleanSelect +from virtualization.models import Cluster, ClusterGroup __all__ = ( 'AggregateBulkEditForm', @@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False - ) min_vid = forms.IntegerField( min_value=VLAN_VID_MIN, max_value=VLAN_VID_MAX, @@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): max_length=200, required=False ) + scope_type = ContentTypeChoiceField( + label=_('Scope type'), + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + required=False + ) + scope_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput() + ) + region = DynamicModelChoiceField( + label=_('Region'), + queryset=Region.objects.all(), + required=False + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site = DynamicModelChoiceField( + label=_('Site'), + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$sitegroup', + } + ) + location = DynamicModelChoiceField( + label=_('Location'), + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + label=_('Rack'), + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + clustergroup = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + label=_('Cluster group') + ) + cluster = DynamicModelChoiceField( + label=_('Cluster'), + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$clustergroup', + } + ) model = VLANGroup fieldsets = ( (None, ('site', 'min_vid', 'max_vid', 'description')), + (_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) - nullable_fields = ('site', 'description') + nullable_fields = ('description',) + + def clean(self): + super().clean() + + # Assign scope based on scope_type + if self.cleaned_data.get('scope_type'): + scope_field = self.cleaned_data['scope_type'].model + if scope_obj := self.cleaned_data.get(scope_field): + self.cleaned_data['scope_id'] = scope_obj.pk + self.changed_data.append('scope_id') + else: + self.cleaned_data.pop('scope_type') + self.changed_data.remove('scope_type') class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index ac3c99468..ed3ceec2b 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm): choices=ServiceProtocolChoices, help_text=_('IP protocol') ) + ipaddresses = CSVModelMultipleChoiceField( + queryset=IPAddress.objects.all(), + required=False, + to_field_name='address', + help_text=_('IP Address'), + ) class Meta: model = Service - fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags') + fields = ( + 'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', + ) + + def clean_ipaddresses(self): + parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') + for ip_address in self.cleaned_data['ipaddresses']: + if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: + raise forms.ValidationError( + _("{ip} is not assigned to this device/VM.").format(ip=ip_address) + ) + + return self.cleaned_data['ipaddresses'] class L2VPNImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index e4e967f81..a8ca91901 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -295,7 +295,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = IPAddress fieldsets = ( (None, ('q', 'filter_id', 'tag')), - (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')), + (_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')), (_('VRF'), ('vrf_id', 'present_in_vrf_id')), (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Device/VM'), ('device_id', 'virtual_machine_id')), @@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + dns_name = forms.CharField( + required=False, + label=_('DNS Name') + ) tag = TagFilterField(model) @@ -519,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Attributes'), ('protocol', 'port')), + (_('Assignment'), ('device_id', 'virtual_machine_id')), + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device'), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Virtual Machine'), + ) tag = TagFilterField(model) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 2e0c4bd30..a05145d6e 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -215,6 +215,9 @@ class PrefixForm(TenancyForm, NetBoxModelForm): queryset=VLAN.objects.all(), required=False, selector=True, + query_params={ + 'site_id': '$site', + }, label=_('VLAN'), ) role = DynamicModelChoiceField( @@ -351,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: raise ValidationError( _("Cannot reassign IP address while it is designated as the primary IP for the parent object") ) @@ -369,14 +372,14 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): # Do not allow assigning a network ID or broadcast address to an interface. if interface and (address := self.cleaned_data.get('address')): if address.ip == address.network: - msg = _("{address} is a network ID, which may not be assigned to an interface.").format(address=address) + msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip) if address.version == 4 and address.prefixlen not in (31, 32): raise ValidationError(msg) if address.version == 6 and address.prefixlen not in (127, 128): raise ValidationError(msg) if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): - msg = _("{address} is a broadcast address, which may not be assigned to an interface.").format( - address=address + msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format( + ip=address.ip ) raise ValidationError(msg) @@ -728,7 +731,7 @@ class ServiceCreateForm(ServiceForm): class Meta(ServiceForm.Meta): fields = [ 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', - 'tags', + 'comments', 'tags', ] def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 5d355102f..1e4e7dac3 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,13 +1,12 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * from ipam.constants import * +from netbox.models import ChangeLoggedModel, PrimaryModel __all__ = ( 'FHRPGroup', @@ -78,7 +77,7 @@ class FHRPGroup(PrimaryModel): class FHRPGroupAssignment(ChangeLoggedModel): interface_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) interface_id = models.PositiveBigIntegerField() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 553f5eb92..7dc0ac445 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,6 +1,5 @@ import netaddr 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 F @@ -9,6 +8,7 @@ from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -140,8 +140,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): if covering_aggregates: raise ValidationError({ 'prefix': _( - "Aggregates cannot overlap. {} is already covered by an existing aggregate ({})." - ).format(self.prefix, covering_aggregates[0]) + "Aggregates cannot overlap. {prefix} is already covered by an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covering_aggregates[0] + ) }) # Ensure that the aggregate being added does not cover an existing aggregate @@ -150,8 +153,11 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): covered_aggregates = covered_aggregates.exclude(pk=self.pk) if covered_aggregates: raise ValidationError({ - 'prefix': _("Aggregates cannot overlap. {} covers an existing aggregate ({}).").format( - self.prefix, covered_aggregates[0] + 'prefix': _( + "Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate ({aggregate})." + ).format( + prefix=self.prefix, + aggregate=covered_aggregates[0] ) }) @@ -290,8 +296,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): super().__init__(*args, **kwargs) # Cache the original prefix and VRF so we can check if they have changed on post_save - self._prefix = self.prefix - self._vrf_id = self.vrf_id + self._prefix = self.__dict__.get('prefix') + self._vrf_id = self.__dict__.get('vrf_id') def __str__(self): return str(self.prefix) @@ -314,10 +320,11 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'prefix': _("Duplicate prefix found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_prefixes.first(), + 'prefix': _("Duplicate prefix found in {table}: {prefix}").format( + table=table, + prefix=duplicate_prefixes.first(), ) }) @@ -554,25 +561,13 @@ class IPRange(PrimaryModel): # Check that start & end IP versions match if self.start_address.version != self.end_address.version: raise ValidationError({ - 'end_address': _( - "Ending address version (IPv{end_address_version}) does not match starting address " - "(IPv{start_address_version})" - ).format( - end_address_version=self.end_address.version, - start_address_version=self.start_address.version - ) + 'end_address': _("Starting and ending IP address versions must match") }) # Check that the start & end IP prefix lengths match if self.start_address.prefixlen != self.end_address.prefixlen: raise ValidationError({ - 'end_address': _( - "Ending address mask (/{end_address_prefixlen}) does not match starting address mask " - "(/{start_address_prefixlen})" - ).format( - end_address_prefixlen=self.end_address.prefixlen, - start_address_prefixlen=self.start_address.prefixlen - ) + 'end_address': _("Starting and ending IP address masks must match") }) # Check that the ending address is greater than the starting address @@ -745,7 +740,7 @@ class IPAddress(PrimaryModel): help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+', @@ -794,6 +789,13 @@ class IPAddress(PrimaryModel): def __str__(self): return str(self.address) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Denote the original assigned object (if any) for validation in clean() + self._original_assigned_object_id = self.__dict__.get('assigned_object_id') + self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + def get_absolute_url(self): return reverse('ipam:ipaddress', args=[self.pk]) @@ -848,13 +850,34 @@ class IPAddress(PrimaryModel): self.role not in IPADDRESS_ROLES_NONUNIQUE or any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) ): + table = _("VRF {vrf}").format(vrf=self.vrf) if self.vrf else _("global table") raise ValidationError({ - 'address': _("Duplicate IP address found in {}: {}").format( - _("VRF {}").format(self.vrf) if self.vrf else _("global table"), - duplicate_ips.first(), + 'address': _("Duplicate IP address found in {table}: {ipaddress}").format( + table=table, + ipaddress=duplicate_ips.first(), ) }) + if self._original_assigned_object_id and self._original_assigned_object_type_id: + parent = getattr(self.assigned_object, 'parent_object', None) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) + original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) + original_parent = getattr(original_assigned_object, 'parent_object', None) + + # can't use is_primary_ip as self.assigned_object might be changed + is_primary = False + if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk: + is_primary = True + if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk: + is_primary = True + + if is_primary and (parent != original_parent): + raise ValidationError({ + 'assigned_object': _( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + }) + # Validate IP status selection if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6: raise ValidationError({ @@ -892,7 +915,7 @@ class IPAddress(PrimaryModel): def is_oob_ip(self): if self.assigned_object: parent = getattr(self.assigned_object, 'parent_object', None) - if parent.oob_ip_id == self.pk: + if hasattr(parent, 'oob_ip') and parent.oob_ip_id == self.pk: return True return False @@ -900,9 +923,9 @@ class IPAddress(PrimaryModel): def is_primary_ip(self): if self.assigned_object: parent = getattr(self.assigned_object, 'parent_object', None) - if self.family == 4 and parent.primary_ip4_id == self.pk: + if self.family == 4 and hasattr(parent, 'primary_ip4') and parent.primary_ip4_id == self.pk: return True - if self.family == 6 and parent.primary_ip6_id == self.pk: + if self.family == 6 and hasattr(parent, 'primary_ip6') and parent.primary_ip6_id == self.pk: return True return False diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py index 2c163179e..0e4818bea 100644 --- a/netbox/ipam/models/l2vpn.py +++ b/netbox/ipam/models/l2vpn.py @@ -1,11 +1,11 @@ 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.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from ipam.choices import L2VPNTypeChoices from ipam.constants import L2VPN_ASSIGNMENT_MODELS from netbox.models import NetBoxModel, PrimaryModel @@ -93,7 +93,7 @@ class L2VPNTermination(NetBoxModel): blank=True, ) assigned_object_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=L2VPN_ASSIGNMENT_MODELS, on_delete=models.PROTECT, related_name='+' diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index aa5b36a57..b6aed5398 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,5 +1,4 @@ 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 @@ -32,7 +31,7 @@ class VLANGroup(OrganizationalModel): max_length=100 ) scope_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE, limit_choices_to=Q(model__in=VLANGROUP_SCOPE_TYPES), blank=True, @@ -234,8 +233,8 @@ class VLAN(PrimaryModel): if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid: raise ValidationError({ 'vid': _( - "VID must be between {min_vid} and {max_vid} for VLANs in group {group}" - ).format(min_vid=self.group.min_vid, max_vid=self.group.max_vid, group=self.group) + "VID must be between {minimum} and {maximum} for VLANs in group {group}" + ).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group) }) def get_status_color(self): diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 4d97bf5f0..c08acce1b 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex): ('date_added', 2000), ('comments', 5000), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -20,6 +21,7 @@ class ASNIndex(SearchIndex): ('asn', 100), ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex): fields = ( ('description', 500), ) + display_attrs = ('rir', 'tenant', 'description') @register_search @@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('protocol', 'auth_type', 'description') @register_search @@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('vrf', 'tenant', 'status', 'role', 'description') @register_search @@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'identifier', 'tenant', 'description') @register_search @@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description') @register_search @@ -92,6 +100,7 @@ class RIRIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -102,6 +111,7 @@ class RoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('tenant', 'description') @register_search @@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('device', 'virtual_machine', 'description') @register_search @@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('description',) @register_search @@ -143,6 +156,7 @@ class VLANIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description') @register_search @@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex): ('description', 500), ('max_vid', 2000), ) + display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description') @register_search @@ -165,3 +180,4 @@ class VRFIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('rd', 'tenant', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cfc9faac1..f62f79f4a 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): ) IPAddress.objects.bulk_create(ip_addresses) + def test_assign_object(self): + """ + Test the creation of available IP addresses within a parent IP range. + """ + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + role = DeviceRole.objects.create(name='Switch') + device1 = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset') + interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset') + device2 = Device.objects.create( + name='Device 2', + site=site, + device_type=device_type, + role=role, + status='active' + ) + interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset') + + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1), + IPAddress(address=IPNetwork('192.168.1.4/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + + ip1 = ip_addresses[0] + ip1.assigned_object = interface1 + device1.primary_ip4 = ip_addresses[0] + device1.save() + + ip2 = ip_addresses[1] + + url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk}) + self.add_permissions('ipam.change_ipaddress') + + # assign to same parent + data = { + 'assigned_object_id': interface2.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # assign to same different parent - should error + data = { + 'assigned_object_id': interface3.pk + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class FHRPGroupTest(APIViewTestCases.APIViewTestCase): model = FHRPGroup diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c1ba4c4ff..4e4eb514f 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': ['24']} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'mask_length__gte': 32} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__lte': 24} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) def test_vrf(self): vrfs = VRF.objects.all()[:2] @@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': [24]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + params = {'mask_length__gte': 64} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'mask_length__lte': 25} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_vrf(self): vrfs = VRF.objects.all()[:2] diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index afc97cc63..a37584f0f 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,6 +4,7 @@ from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork +from dcim.constants import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from ipam.choices import * from ipam.models import * @@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1') role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role) + interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) services = ( Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), @@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Service.objects.bulk_create(services) + ip_addresses = ( + IPAddress(assigned_object=interface, address='192.0.2.1/24'), + IPAddress(assigned_object=interface, address='192.0.2.2/24'), + ) + IPAddress.objects.bulk_create(ip_addresses) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device,name,protocol,ports,description", - "Device 1,Service 1,tcp,1,First service", - "Device 1,Service 2,tcp,2,Second service", - "Device 1,Service 3,udp,3,Third service", + "device,name,protocol,ports,ipaddresses,description", + "Device 1,Service 1,tcp,1,192.0.2.1/24,First service", + "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", + "Device 1,Service 3,udp,3,,Third service", ) cls.csv_update_data = ( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 490cf940b..48ea637d9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,7 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import F, Prefetch from django.db.models.expressions import RawSQL -from django.db.models.functions import Round from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -11,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic from tenancy.views import ObjectContactsView +from utilities.tables import get_table_ordering from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet @@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView): tab = ViewTab( label=_('ASNs'), badge=lambda x: x.get_child_asns().count(), - permission='ipam.view_asns', + permission='ipam.view_asn', weight=500 ) @@ -606,7 +606,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') def prep_table_data(self, request, queryset, parent): - if not request.GET.get('q') and not request.GET.get('sort'): + if not get_table_ordering(request, self.table): return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool) return queryset @@ -952,7 +952,9 @@ class VLANGroupVLANsView(generic.ObjectChildrenView): ) def prep_table_data(self, request, queryset, parent): - return add_available_vlans(parent.get_child_vlans(), parent) + if not get_table_ordering(request, self.table): + return add_available_vlans(parent.get_child_vlans(), parent) + return queryset # diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 347ed55bd..d6e43ea75 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -46,12 +46,13 @@ class ChoiceField(serializers.Field): return super().validate_empty_values(data) def to_representation(self, obj): - if obj == '': - return None - return { - 'value': obj, - 'label': self._choices[obj], - } + if obj != '': + # Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously + # configured choice has been removed from FIELD_CHOICES). + return { + 'value': obj, + 'label': self._choices.get(obj, ''), + } def to_internal_value(self, data): if data == '': diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 97f690762..4e71ca193 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -11,7 +11,7 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView from rq.worker import Worker -from extras.plugins.utils import get_installed_plugins +from netbox.plugins.utils import get_installed_plugins from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 5fe81b1f5..522bcf77b 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -2,7 +2,9 @@ import logging from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction -from django.db.models import ProtectedError +from django.db.models import ProtectedError, RestrictedError +from django_pglocks import advisory_lock +from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet @@ -89,8 +91,11 @@ class NetBoxModelViewSet( try: return super().dispatch(request, *args, **kwargs) - except ProtectedError as e: - protected_objects = list(e.protected_objects) + except (ProtectedError, RestrictedError) as e: + if type(e) is ProtectedError: + protected_objects = list(e.protected_objects) + else: + protected_objects = list(e.restricted_objects) msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) logger.warning(msg) @@ -157,3 +162,22 @@ class NetBoxModelViewSet( logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") return super().perform_destroy(instance) + + +class MPTTLockedMixin: + """ + Puts pglock on objects that derive from MPTTModel for parallel API calling. + Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS + """ + + def create(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]): + return super().destroy(request, *args, **kwargs) diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index fde486fe9..7b6c00843 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -137,11 +137,14 @@ class BulkUpdateModelMixin: } ] """ + def get_bulk_update_queryset(self): + return self.get_queryset() + def bulk_update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_update_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) @@ -184,10 +187,13 @@ class BulkDestroyModelMixin: {"id": 456} ] """ + def get_bulk_destroy_queryset(self): + return self.get_queryset() + def bulk_destroy(self, request, *args, **kwargs): serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - qs = self.get_queryset().filter( + qs = self.get_bulk_destroy_queryset().filter( pk__in=[o['id'] for o in serializer.data] ) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8be5c97a9..0cdf8a8d2 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -152,42 +152,17 @@ PARAMS = ( description=_("Custom validation rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), }, ), - - # NAPALM ConfigParam( - name='NAPALM_USERNAME', - label=_('NAPALM username'), - default='', - description=_("Username to use when connecting to devices via NAPALM") - ), - ConfigParam( - name='NAPALM_PASSWORD', - label=_('NAPALM password'), - default='', - description=_("Password to use when connecting to devices via NAPALM") - ), - ConfigParam( - name='NAPALM_TIMEOUT', - label=_('NAPALM timeout'), - default=30, - description=_("NAPALM connection timeout (in seconds)"), - field=forms.IntegerField - ), - ConfigParam( - name='NAPALM_ARGS', - label=_('NAPALM arguments'), + name='PROTECTION_RULES', + label=_('Protection rules'), default={}, - description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"), + description=_("Deletion protection rules (JSON)"), field=forms.JSONField, field_kwargs={ - 'widget': forms.Textarea( - attrs={'class': 'vLargeTextField'} - ), + 'widget': forms.Textarea(), }, ), diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 18a3c2afa..cec05cabb 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -15,7 +15,7 @@ DATABASE = { } PLUGINS = [ - 'extras.tests.dummy_plugin', + 'netbox.tests.dummy_plugin', ] REDIS = { diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index d69edc69c..faddf8c21 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -11,8 +11,28 @@ RQ_QUEUE_LOW = 'low' # When adding a new key, pick something arbitrary and unique so that it is easily searchable in # query logs. ADVISORY_LOCK_KEYS = { + # Available object locks 'available-prefixes': 100100, 'available-ips': 100200, 'available-vlans': 100300, 'available-asns': 100400, + + # MPTT locks + 'region': 105100, + 'sitegroup': 105200, + 'location': 105300, + 'tenantgroup': 105400, + 'contactgroup': 105500, + 'wirelesslangroup': 105600, + 'inventoryitem': 105700, + 'inventoryitemtemplate': 105800, +} + +# Default view action permission mapping +DEFAULT_ACTION_PERMISSIONS = { + 'add': {'add'}, + 'import': {'add'}, + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, } diff --git a/netbox/netbox/data_backends.py b/netbox/netbox/data_backends.py new file mode 100644 index 000000000..d5bab75c1 --- /dev/null +++ b/netbox/netbox/data_backends.py @@ -0,0 +1,53 @@ +from contextlib import contextmanager +from urllib.parse import urlparse + +__all__ = ( + 'DataBackend', +) + + +class DataBackend: + """ + A data backend represents a specific system of record for data, such as a git repository or Amazon S3 bucket. + + Attributes: + name: The identifier under which this backend will be registered in NetBox + label: The human-friendly name for this backend + is_local: A boolean indicating whether this backend accesses local data + parameters: A dictionary mapping configuration form field names to their classes + sensitive_parameters: An iterable of field names for which the values should not be displayed to the user + """ + is_local = False + 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): + """ + A hook to initialize the instance's configuration. The data returned by this method is assigned to the + instance's `config` attribute upon initialization, which can be referenced by the `fetch()` method. + """ + return + + @property + def url_scheme(self): + return urlparse(self.url).scheme.lower() + + @contextmanager + def fetch(self): + """ + A context manager which performs the following: + + 1. Handles all setup and synchronization + 2. Yields the local path at which data has been replicated + 3. Performs any necessary cleanup + """ + raise NotImplemented() diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index c5dac90f7..b51efe9c0 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -3,11 +3,12 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices -from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin +from extras.choices import * +from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin from extras.models import CustomField, Tag -from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin +from utilities.forms import CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField +from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin __all__ = ( 'NetBoxModelForm', @@ -17,7 +18,7 @@ __all__ = ( ) -class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm): +class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. @@ -26,18 +27,6 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, the rendered form (optional). If not defined, the all fields will be rendered as a single section. """ fieldsets = () - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False, - label=_('Tags'), - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit tags to those applicable to the object type - if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'): - self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk) def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) @@ -87,11 +76,9 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): ) def _get_custom_fields(self, content_type): - return CustomField.objects.filter(content_types=content_type).filter( - ui_visibility__in=[ - CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, - CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET, - ] + return CustomField.objects.filter( + content_types=content_type, + ui_editable=CustomFieldUIEditableChoices.YES ) def _get_form_field(self, customfield): @@ -142,7 +129,8 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): def _extend_nullable_fields(self): nullable_custom_fields = [ - name for name, customfield in self.custom_fields.items() if (not customfield.required and customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE) + name for name, customfield in self.custom_fields.items() + if (not customfield.required and customfield.ui_editable == CustomFieldUIEditableChoices.YES) ] self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 596357ea4..9d7696696 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): if ct_value and fk_value: klass = getattr(self, field.ct_field).model_class() - if not klass.objects.filter(pk=fk_value).exists(): + try: + obj = klass.objects.get(pk=fk_value) + except ObjectDoesNotExist: raise ValidationError({ field.fk_field: f"Related object not found using the provided value: {fk_value}." }) + # update the GFK field value + setattr(self, field.name, obj) + # # NetBox internal base models diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index cce265efc..f39f35620 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,7 +3,6 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models.signals import class_prepared @@ -13,7 +12,8 @@ from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager from core.choices import JobStatusChoices -from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices +from core.models import ContentType +from extras.choices import * from extras.utils import is_taggable, register_features from netbox.registry import registry from netbox.signals import post_clean @@ -205,12 +205,11 @@ class CustomFieldsMixin(models.Model): for field in CustomField.objects.get_for_model(self): value = self.custom_field_data.get(field.name) - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden: - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: - continue + # Skip hidden fields if 'omit_hidden' is True + if omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.HIDDEN: + continue + elif omit_hidden and field.ui_visible == CustomFieldUIVisibleChoices.IF_SET and not value: + continue data[field] = field.deserialize(value) @@ -232,12 +231,12 @@ class CustomFieldsMixin(models.Model): from extras.models import CustomField groups = defaultdict(dict) visible_custom_fields = CustomField.objects.get_for_model(self).exclude( - ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ui_visible=CustomFieldUIVisibleChoices.HIDDEN ) for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) - if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + if value in (None, []) and cf.ui_visible == CustomFieldUIVisibleChoices.IF_SET: continue value = cf.deserialize(value) groups[cf.group_name][cf] = value diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index a05b1c495..4c7190bbb 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -34,6 +34,7 @@ class MenuItem: link: str link_text: str permissions: Optional[Sequence[str]] = () + staff_only: Optional[bool] = False buttons: Optional[Sequence[MenuItemButton]] = () diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 5c7502a03..43cf3f869 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,4 +1,4 @@ -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from netbox.registry import registry from utilities.choices import ButtonColorChoices @@ -218,6 +218,7 @@ VIRTUALIZATION_MENU = Menu( items=( get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), get_model_item('virtualization', 'vminterface', _('Interfaces')), + get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')), ), ), MenuGroup( @@ -360,6 +361,7 @@ ADMIN_MENU = Menu( link=f'users:netboxuser_list', link_text=_('Users'), permissions=[f'auth.view_user'], + staff_only=True, buttons=( MenuItemButton( link=f'users:netboxuser_add', @@ -382,6 +384,7 @@ ADMIN_MENU = Menu( link=f'users:netboxgroup_list', link_text=_('Groups'), permissions=[f'auth.view_group'], + staff_only=True, buttons=( MenuItemButton( link=f'users:netboxgroup_add', @@ -399,8 +402,20 @@ ADMIN_MENU = Menu( ) ) ), - get_model_item('users', 'token', _('API Tokens')), - get_model_item('users', 'objectpermission', _('Permissions'), actions=['add']), + MenuItem( + link=f'users:token_list', + link_text=_('API Tokens'), + permissions=[f'users.view_token'], + staff_only=True, + buttons=get_model_buttons('users', 'token') + ), + MenuItem( + link=f'users:objectpermission_list', + link_text=_('Permissions'), + permissions=[f'users.view_objectpermission'], + staff_only=True, + buttons=get_model_buttons('users', 'objectpermission', actions=['add']) + ), ), ), MenuGroup( @@ -409,12 +424,14 @@ ADMIN_MENU = Menu( MenuItem( link='core:config', link_text=_('Current Config'), - permissions=['extras.view_configrevision'] + permissions=['extras.view_configrevision'], + staff_only=True ), MenuItem( link='extras:configrevision_list', link_text=_('Config Revisions'), - permissions=['extras.view_configrevision'] + permissions=['extras.view_configrevision'], + staff_only=True ), ), ), diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py new file mode 100644 index 000000000..8b6901b7a --- /dev/null +++ b/netbox/netbox/plugins/__init__.py @@ -0,0 +1,156 @@ +import collections +from importlib import import_module + +from django.apps import AppConfig +from django.core.exceptions import ImproperlyConfigured +from django.utils.module_loading import import_string +from packaging import version + +from netbox.registry import registry +from netbox.search import register_search +from netbox.utils import register_data_backend +from .navigation import * +from .registration import * +from .templates import * +from .utils import * + +# Initialize plugin registry +registry['plugins'].update({ + 'graphql_schemas': [], + 'menus': [], + 'menu_items': {}, + 'preferences': {}, + 'template_extensions': collections.defaultdict(list), +}) + +DEFAULT_RESOURCE_PATHS = { + 'search_indexes': 'search.indexes', + 'data_backends': 'data_backends.backends', + 'graphql_schema': 'graphql.schema', + 'menu': 'navigation.menu', + 'menu_items': 'navigation.menu_items', + 'template_extensions': 'template_content.template_extensions', + 'user_preferences': 'preferences.preferences', +} + + +# +# Plugin AppConfig class +# + +class PluginConfig(AppConfig): + """ + Subclass of Django's built-in AppConfig class, to be used for NetBox plugins. + """ + # Plugin metadata + author = '' + author_email = '' + description = '' + version = '' + + # Root URL path under /plugins. If not set, the plugin's label will be used. + base_url = None + + # Minimum/maximum compatible versions of NetBox + min_version = None + max_version = None + + # Default configuration parameters + default_settings = {} + + # Mandatory configuration parameters + required_settings = [] + + # Middleware classes provided by the plugin + middleware = [] + + # Django-rq queues dedicated to the plugin + queues = [] + + # Django apps to append to INSTALLED_APPS when plugin requires them. + django_apps = [] + + # Optional plugin resources + search_indexes = None + data_backends = None + graphql_schema = None + menu = None + menu_items = None + template_extensions = None + user_preferences = None + + def _load_resource(self, name): + # Import from the configured path, if defined. + if path := getattr(self, name, None): + return import_string(f"{self.__module__}.{path}") + + # Fall back to the resource's default path. Return None if the module has not been provided. + default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}' + default_module, resource_name = default_path.rsplit('.', 1) + try: + module = import_module(default_module) + return getattr(module, resource_name, None) + except ModuleNotFoundError: + pass + + def ready(self): + plugin_name = self.name.rsplit('.', 1)[-1] + + # Register search extensions (if defined) + search_indexes = self._load_resource('search_indexes') or [] + for idx in search_indexes: + register_search(idx) + + # Register data backends (if defined) + data_backends = self._load_resource('data_backends') or [] + for backend in data_backends: + register_data_backend()(backend) + + # Register template content (if defined) + if template_extensions := self._load_resource('template_extensions'): + register_template_extensions(template_extensions) + + # Register navigation menu and/or menu items (if defined) + if menu := self._load_resource('menu'): + register_menu(menu) + if menu_items := self._load_resource('menu_items'): + register_menu_items(self.verbose_name, menu_items) + + # Register GraphQL schema (if defined) + if graphql_schema := self._load_resource('graphql_schema'): + register_graphql_schema(graphql_schema) + + # Register user preferences (if defined) + if user_preferences := self._load_resource('user_preferences'): + register_user_preferences(plugin_name, user_preferences) + + @classmethod + def validate(cls, user_config, netbox_version): + + # Enforce version constraints + current_version = version.parse(netbox_version) + if cls.min_version is not None: + min_version = version.parse(cls.min_version) + if current_version < min_version: + raise ImproperlyConfigured( + f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}." + ) + if cls.max_version is not None: + max_version = version.parse(cls.max_version) + if current_version > max_version: + raise ImproperlyConfigured( + f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}." + ) + + # Verify required configuration settings + for setting in cls.required_settings: + if setting not in user_config: + raise ImproperlyConfigured( + f"Plugin {cls.__module__} requires '{setting}' to be present in the PLUGINS_CONFIG section of " + f"configuration.py." + ) + + # Apply default configuration values + for setting, value in cls.default_settings.items(): + if setting not in user_config: + user_config[setting] = value diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py new file mode 100644 index 000000000..2075c97b6 --- /dev/null +++ b/netbox/netbox/plugins/navigation.py @@ -0,0 +1,72 @@ +from netbox.navigation import MenuGroup +from utilities.choices import ButtonColorChoices +from django.utils.text import slugify + +__all__ = ( + 'PluginMenu', + 'PluginMenuButton', + 'PluginMenuItem', +) + + +class PluginMenu: + icon_class = 'mdi mdi-puzzle' + + def __init__(self, label, groups, icon_class=None): + self.label = label + self.groups = [ + MenuGroup(label, items) for label, items in groups + ] + if icon_class is not None: + self.icon_class = icon_class + + @property + def name(self): + return slugify(self.label) + + +class PluginMenuItem: + """ + This class represents a navigation menu item. This constitutes primary link and its text, but also allows for + specifying additional link buttons that appear to the right of the item in the van menu. + + Links are specified as Django reverse URL strings. + Buttons are each specified as a list of PluginMenuButton instances. + """ + permissions = [] + buttons = [] + + def __init__(self, link, link_text, staff_only=False, permissions=None, buttons=None): + self.link = link + self.link_text = link_text + self.staff_only = staff_only + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if buttons is not None: + if type(buttons) not in (list, tuple): + raise TypeError("Buttons must be passed as a tuple or list.") + self.buttons = buttons + + +class PluginMenuButton: + """ + This class represents a button within a PluginMenuItem. Note that button colors should come from + ButtonColorChoices. + """ + color = ButtonColorChoices.DEFAULT + permissions = [] + + def __init__(self, link, title, icon_class, color=None, permissions=None): + self.link = link + self.title = title + self.icon_class = icon_class + if permissions is not None: + if type(permissions) not in (list, tuple): + raise TypeError("Permissions must be passed as a tuple or list.") + self.permissions = permissions + if color is not None: + if color not in ButtonColorChoices.values(): + raise ValueError("Button color must be a choice within ButtonColorChoices.") + self.color = color diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py new file mode 100644 index 000000000..3be538441 --- /dev/null +++ b/netbox/netbox/plugins/registration.py @@ -0,0 +1,64 @@ +import inspect + +from netbox.registry import registry +from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem +from .templates import PluginTemplateExtension + +__all__ = ( + 'register_graphql_schema', + 'register_menu', + 'register_menu_items', + 'register_template_extensions', + 'register_user_preferences', +) + + +def register_template_extensions(class_list): + """ + Register a list of PluginTemplateExtension classes + """ + # Validation + for template_extension in class_list: + if not inspect.isclass(template_extension): + raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!") + if not issubclass(template_extension, PluginTemplateExtension): + raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!") + if template_extension.model is None: + raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!") + + registry['plugins']['template_extensions'][template_extension.model].append(template_extension) + + +def register_menu(menu): + if not isinstance(menu, PluginMenu): + raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu") + registry['plugins']['menus'].append(menu) + + +def register_menu_items(section_name, class_list): + """ + Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) + """ + # Validation + for menu_link in class_list: + if not isinstance(menu_link, PluginMenuItem): + raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem") + for button in menu_link.buttons: + if not isinstance(button, PluginMenuButton): + raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton") + + registry['plugins']['menu_items'][section_name] = class_list + + +def register_graphql_schema(graphql_schema): + """ + Register a GraphQL schema class for inclusion in NetBox's GraphQL API. + """ + registry['plugins']['graphql_schemas'].append(graphql_schema) + + +def register_user_preferences(plugin_name, preferences): + """ + Register a list of user preferences defined by a plugin. + """ + registry['plugins']['preferences'][plugin_name] = preferences diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py new file mode 100644 index 000000000..e9b9a9dca --- /dev/null +++ b/netbox/netbox/plugins/templates.py @@ -0,0 +1,73 @@ +from django.template.loader import get_template + +__all__ = ( + 'PluginTemplateExtension', +) + + +class PluginTemplateExtension: + """ + This class is used to register plugin content to be injected into core NetBox templates. It contains methods + that are overridden by plugin authors to return template content. + + The `model` attribute on the class defines the which model detail page this class renders content for. It + should be set as a string in the form '.'. render() provides the following context data: + + * object - The object being viewed + * request - The current request + * settings - Global NetBox settings + * config - Plugin-specific configuration parameters + """ + model = None + + def __init__(self, context): + self.context = context + + def render(self, template_name, extra_context=None): + """ + Convenience method for rendering the specified Django template using the default context data. An additional + context dictionary may be passed as `extra_context`. + """ + if extra_context is None: + extra_context = {} + elif not isinstance(extra_context, dict): + raise TypeError("extra_context must be a dictionary") + + return get_template(template_name).render({**self.context, **extra_context}) + + def left_page(self): + """ + Content that will be rendered on the left of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def right_page(self): + """ + Content that will be rendered on the right of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def full_width_page(self): + """ + Content that will be rendered within the full width of the detail page view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError + + def list_buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the list view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError diff --git a/netbox/netbox/plugins/urls.py b/netbox/netbox/plugins/urls.py new file mode 100644 index 000000000..2f237f56a --- /dev/null +++ b/netbox/netbox/plugins/urls.py @@ -0,0 +1,41 @@ +from importlib import import_module + +from django.apps import apps +from django.conf import settings +from django.conf.urls import include +from django.contrib.admin.views.decorators import staff_member_required +from django.urls import path +from django.utils.module_loading import import_string, module_has_submodule + +from . import views + +# Initialize URL base, API, and admin URL patterns for plugins +plugin_patterns = [] +plugin_api_patterns = [ + path('', views.PluginsAPIRootView.as_view(), name='api-root'), + path('installed-plugins/', views.InstalledPluginsAPIView.as_view(), name='plugins-list') +] +plugin_admin_patterns = [ + path('installed-plugins/', staff_member_required(views.InstalledPluginsAdminView.as_view()), name='plugins_list') +] + +# Register base/API URL patterns for each plugin +for plugin_path in settings.PLUGINS: + plugin = import_module(plugin_path) + plugin_name = plugin_path.split('.')[-1] + app = apps.get_app_config(plugin_name) + base_url = getattr(app, 'base_url') or app.label + + # Check if the plugin specifies any base URLs + if module_has_submodule(plugin, 'urls'): + urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") + plugin_patterns.append( + path(f"{base_url}/", include((urlpatterns, app.label))) + ) + + # Check if the plugin specifies any API URLs + if module_has_submodule(plugin, 'api.urls'): + urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") + plugin_api_patterns.append( + path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) + ) diff --git a/netbox/netbox/plugins/utils.py b/netbox/netbox/plugins/utils.py new file mode 100644 index 000000000..c260f156d --- /dev/null +++ b/netbox/netbox/plugins/utils.py @@ -0,0 +1,37 @@ +from django.apps import apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +__all__ = ( + 'get_installed_plugins', + 'get_plugin_config', +) + + +def get_installed_plugins(): + """ + Return a dictionary mapping the names of installed plugins to their versions. + """ + plugins = {} + for plugin_name in settings.PLUGINS: + plugin_name = plugin_name.rsplit('.', 1)[-1] + plugin_config = apps.get_app_config(plugin_name) + plugins[plugin_name] = getattr(plugin_config, 'version', None) + + return dict(sorted(plugins.items())) + + +def get_plugin_config(plugin_name, parameter, default=None): + """ + Return the value of the specified plugin configuration parameter. + + Args: + plugin_name: The name of the plugin + parameter: The name of the configuration parameter + default: The value to return if the parameter is not defined (default: None) + """ + try: + plugin_config = settings.PLUGINS_CONFIG[plugin_name] + return plugin_config.get(parameter, default) + except KeyError: + raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.") diff --git a/netbox/netbox/plugins/views.py b/netbox/netbox/plugins/views.py new file mode 100644 index 000000000..5971f78ef --- /dev/null +++ b/netbox/netbox/plugins/views.py @@ -0,0 +1,89 @@ +from collections import OrderedDict + +from django.apps import apps +from django.conf import settings +from django.shortcuts import render +from django.urls.exceptions import NoReverseMatch +from django.views.generic import View +from drf_spectacular.utils import extend_schema +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.views import APIView + + +class InstalledPluginsAdminView(View): + """ + Admin view for listing all installed plugins + """ + def get(self, request): + plugins = [apps.get_app_config(plugin) for plugin in settings.PLUGINS] + return render(request, 'extras/admin/plugins_list.html', { + 'plugins': plugins, + }) + + +@extend_schema(exclude=True) +class InstalledPluginsAPIView(APIView): + """ + API view for listing all installed plugins + """ + permission_classes = [permissions.IsAdminUser] + _ignore_model_permissions = True + schema = None + + def get_view_name(self): + return "Installed Plugins" + + @staticmethod + def _get_plugin_data(plugin_app_config): + return { + 'name': plugin_app_config.verbose_name, + 'package': plugin_app_config.name, + 'author': plugin_app_config.author, + 'author_email': plugin_app_config.author_email, + 'description': plugin_app_config.description, + 'version': plugin_app_config.version + } + + def get(self, request, format=None): + return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS]) + + +@extend_schema(exclude=True) +class PluginsAPIRootView(APIView): + _ignore_model_permissions = True + schema = None + + def get_view_name(self): + return "Plugins" + + @staticmethod + def _get_plugin_entry(plugin, app_config, request, format): + # Check if the plugin specifies any API URLs + api_app_name = f'{app_config.name}-api' + try: + entry = (getattr(app_config, 'base_url', app_config.label), reverse( + f"plugins-api:{api_app_name}:api-root", + request=request, + format=format + )) + except NoReverseMatch: + # The plugin does not include an api-root url + entry = None + + return entry + + def get(self, request, format=None): + + entries = [] + for plugin in settings.PLUGINS: + app_config = apps.get_app_config(plugin) + entry = self._get_plugin_entry(plugin, app_config, request, format) + if entry is not None: + entries.append(entry) + + return Response(OrderedDict(( + ('installed-plugins', reverse('plugins-api:plugins-list', request=request, format=format)), + *entries + ))) diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 21a869001..ad8c18dcf 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -25,8 +25,10 @@ registry = Registry({ 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), 'model_features': dict(), + 'models': collections.defaultdict(set), 'plugins': dict(), 'search': dict(), + 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), 'widgets': dict(), }) diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 6d53e9a97..590188f21 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -33,10 +33,12 @@ class SearchIndex: category: The label of the group under which this indexer is categorized (for form field display). If none, the name of the model's app will be used. fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each. + display_attrs: An iterable of additional object attributes to include when displaying search results. """ model = None category = None fields = () + display_attrs = () @staticmethod def get_field_type(instance, field_name): diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 4487b6bb8..1fb23a37c 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -3,7 +3,8 @@ from collections import defaultdict from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured -from django.db.models import F, Window, Q +from django.db.models import F, Window, Q, prefetch_related_objects +from django.db.models.fields.related import ForeignKey from django.db.models.functions import window from django.db.models.signals import post_delete, post_save from django.utils.module_loading import import_string @@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError from extras.models import CachedValue, CustomField from netbox.registry import registry from utilities.querysets import RestrictedPrefetch -from utilities.utils import title +from utilities.utils import content_type_identifier, title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend): def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE): + # Build the filter used to find relevant CachedValue records query_filter = Q(**{f'value__{lookup}': value}) - if object_types: + # Limit results by object type query_filter &= Q(object_type__in=object_types) - if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH): - # Partial string matches are valid only on string values + # "Starts/ends with" matches are valid only on string values query_filter &= Q(type=FieldTypes.STRING) - - if lookup == LookupTypes.PARTIAL: + elif lookup == LookupTypes.PARTIAL: try: + # If the value looks like an IP address, add an extra match for CIDR values address = str(netaddr.IPNetwork(value.strip()).cidr) query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) except (AddrFormatError, ValueError): @@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend): ) )[:MAX_RESULTS] + # Gather all ContentTypes present in the search results (used for prefetching related + # objects). This must be done before generating the final results list, which returns + # a RawQuerySet. + content_type_ids = set(queryset.values_list('object_type', flat=True)) + content_types = ContentType.objects.filter(pk__in=content_type_ids) + # Construct a Prefetch to pre-fetch only those related objects for which the # user has permission to view. if user: @@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend): params ) + # Iterate through each ContentType represented in the search results and prefetch any + # related objects necessary to render the prescribed display attributes (display_attrs). + for ct in content_types: + model = ct.model_class() + indexer = registry['search'].get(content_type_identifier(ct)) + if not (display_attrs := getattr(indexer, 'display_attrs', None)): + continue + + # Add ForeignKey fields to prefetch list + prefetch_fields = [] + for attr in display_attrs: + field = model._meta.get_field(attr) + if type(field) is ForeignKey: + prefetch_fields.append(f'object__{attr}') + + # Compile a list of all CachedValues referencing this object type, and prefetch + # any related objects + if prefetch_fields: + objects = [r for r in results if r.object_type == ct] + prefetch_related_objects(objects, *prefetch_fields) + # Omit any results pertaining to an object the user does not have permission to view ret = [] for r in results: if r.object is not None: r.name = str(r.object) ret.append(r) + return ret def cache(self, instances, indexer=None, remove_existing=True): diff --git a/netbox/netbox/search/utils.py b/netbox/netbox/search/utils.py new file mode 100644 index 000000000..824fbfb3d --- /dev/null +++ b/netbox/netbox/search/utils.py @@ -0,0 +1,14 @@ +from netbox.registry import registry +from utilities.utils import content_type_identifier + +__all__ = ( + 'get_indexer', +) + + +def get_indexer(content_type): + """ + Return the registered search indexer for the given ContentType. + """ + ct_identifier = content_type_identifier(content_type) + return registry['search'].get(ct_identifier) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4ad783161..465389a11 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -9,23 +9,25 @@ import warnings from urllib.parse import urlencode, urlsplit import django -import sentry_sdk from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_str -from extras.plugins import PluginConfig -from sentry_sdk.integrations.django import DjangoIntegration +try: + import sentry_sdk +except ModuleNotFoundError: + pass from netbox.config import PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW +from netbox.plugins import PluginConfig # # Environment setup # -VERSION = '3.6.1-dev' +VERSION = '3.6.6-dev' # Hostname HOSTNAME = platform.node() @@ -39,8 +41,6 @@ if sys.version_info < (3, 8): f"NetBox requires Python 3.8 or later. (Currently installed: Python {platform.python_version()})" ) -DEFAULT_SENTRY_DSN = 'https://198cf560b29d4054ab8e583a1d10ea58@o1242133.ingest.sentry.io/6396485' - # # Configuration import # @@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) +DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) @@ -157,7 +158,7 @@ RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) -SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN) +SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) @@ -355,6 +356,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'django.forms', 'corsheaders', 'debug_toolbar', 'graphiql_debug_toolbar', @@ -430,6 +432,9 @@ TEMPLATES = [ }, ] +# This allows us to override Django's stock form widget templates +FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' + # Set up authentication backends if type(REMOTE_AUTH_BACKEND) not in (list, tuple): REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND] @@ -496,6 +501,10 @@ AUTH_EXEMPT_PATHS = ( # All URLs starting with a string listed here are exempt from maintenance mode enforcement MAINTENANCE_EXEMPT_PATHS = ( f'/{BASE_PATH}admin/', + f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration + LOGIN_URL, + LOGIN_REDIRECT_URL, + LOGOUT_REDIRECT_URL ) SERIALIZATION_MODULES = { @@ -508,12 +517,12 @@ SERIALIZATION_MODULES = { # if SENTRY_ENABLED: + try: + from sentry_sdk.integrations.django import DjangoIntegration + except ModuleNotFoundError: + raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.") if not SENTRY_DSN: raise ImproperlyConfigured("SENTRY_ENABLED is True but SENTRY_DSN has not been defined.") - # If using the default DSN, force sampling rates - if SENTRY_DSN == DEFAULT_SENTRY_DSN: - SENTRY_SAMPLE_RATE = 1.0 - SENTRY_TRACES_SAMPLE_RATE = 0 # Initialize the SDK sentry_sdk.init( dsn=SENTRY_DSN, @@ -528,9 +537,6 @@ if SENTRY_ENABLED: # Assign any configured tags for k, v in SENTRY_TAGS.items(): sentry_sdk.set_tag(k, v) - # If using the default DSN, append a unique deployment ID tag for error correlation - if SENTRY_DSN == DEFAULT_SENTRY_DSN: - sentry_sdk.set_tag('netbox.deployment_id', DEPLOYMENT_ID) # diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 9e348fb23..d2cd0a0d4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column): return mark_safe('') if self.customfield.type == CustomFieldTypeChoices.TYPE_URL: return mark_safe(f'{escape(value)}') + if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT: + return self.customfield.get_choice_label(value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: - return ', '.join(v for v in value) + return ', '.join(self.customfield.get_choice_label(v) for v in value) if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: return mark_safe(', '.join( self._linkify_item(obj) for obj in self.customfield.deserialize(value) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 52ff69aa9..495e56991 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import django_tables2 as tables from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.fields import GenericForeignKey @@ -10,11 +12,13 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from django_tables2.data import TableQuerysetData +from extras.choices import * from extras.models import CustomField, CustomLink -from extras.choices import CustomFieldVisibilityChoices +from netbox.registry import registry from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.utils import get_viewname, highlight_string, title +from .template_code import * __all__ = ( 'BaseTable', @@ -119,7 +123,7 @@ class BaseTable(tables.Table): @property def available_columns(self): - return self._get_columns(visible=False) + return sorted(self._get_columns(visible=False)) @property def selected_columns(self): @@ -190,12 +194,17 @@ class NetBoxTable(BaseTable): if extra_columns is None: extra_columns = [] + if registered_columns := registry['tables'].get(self.__class__): + extra_columns.extend([ + # Create a copy to avoid modifying the original Column + (name, deepcopy(column)) for name, column in registered_columns.items() + ]) + # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter( content_types=content_type - ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) - + ).exclude(ui_visible=CustomFieldUIVisibleChoices.HIDDEN) extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) @@ -236,6 +245,10 @@ class SearchTable(tables.Table): value = tables.Column( verbose_name=_('Value'), ) + attrs = columns.TemplateColumn( + template_code=SEARCH_RESULT_ATTRS, + verbose_name=_('Attributes') + ) trim_length = 30 diff --git a/netbox/netbox/tables/template_code.py b/netbox/netbox/tables/template_code.py new file mode 100644 index 000000000..24439eeb6 --- /dev/null +++ b/netbox/netbox/tables/template_code.py @@ -0,0 +1,18 @@ +SEARCH_RESULT_ATTRS = """ +{% for name, value in record.display_attrs.items %} + 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %} + > + {{ name|bettertitle }}: + {% with url=value.get_absolute_url %} + {% if url %}{% endif %} + {% if value|length > 40 %} + {{ value|truncatechars:"40" }} + {% else %} + {{ value }} + {% endif %} + {% if url %}{% endif %} + {% endwith %} + +{% endfor %} +""" diff --git a/netbox/extras/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py similarity index 72% rename from netbox/extras/tests/dummy_plugin/__init__.py rename to netbox/netbox/tests/dummy_plugin/__init__.py index 83baf064f..3ade8f9df 100644 --- a/netbox/extras/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -1,8 +1,8 @@ -from extras.plugins import PluginConfig +from netbox.plugins import PluginConfig class DummyPluginConfig(PluginConfig): - name = 'extras.tests.dummy_plugin' + name = 'netbox.tests.dummy_plugin' verbose_name = 'Dummy plugin' version = '0.0' description = 'For testing purposes only' @@ -10,7 +10,7 @@ class DummyPluginConfig(PluginConfig): min_version = '1.0' max_version = '9.0' middleware = [ - 'extras.tests.dummy_plugin.middleware.DummyMiddleware' + 'netbox.tests.dummy_plugin.middleware.DummyMiddleware' ] queues = [ 'testing-low', diff --git a/netbox/extras/tests/dummy_plugin/admin.py b/netbox/netbox/tests/dummy_plugin/admin.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/admin.py rename to netbox/netbox/tests/dummy_plugin/admin.py diff --git a/netbox/extras/tests/dummy_plugin/api/serializers.py b/netbox/netbox/tests/dummy_plugin/api/serializers.py similarity index 76% rename from netbox/extras/tests/dummy_plugin/api/serializers.py rename to netbox/netbox/tests/dummy_plugin/api/serializers.py index 101786168..239d7d998 100644 --- a/netbox/extras/tests/dummy_plugin/api/serializers.py +++ b/netbox/netbox/tests/dummy_plugin/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework.serializers import ModelSerializer -from extras.tests.dummy_plugin.models import DummyModel +from netbox.tests.dummy_plugin.models import DummyModel class DummySerializer(ModelSerializer): diff --git a/netbox/extras/tests/dummy_plugin/api/urls.py b/netbox/netbox/tests/dummy_plugin/api/urls.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/api/urls.py rename to netbox/netbox/tests/dummy_plugin/api/urls.py diff --git a/netbox/extras/tests/dummy_plugin/api/views.py b/netbox/netbox/tests/dummy_plugin/api/views.py similarity index 78% rename from netbox/extras/tests/dummy_plugin/api/views.py rename to netbox/netbox/tests/dummy_plugin/api/views.py index 1977ec2af..58f221285 100644 --- a/netbox/extras/tests/dummy_plugin/api/views.py +++ b/netbox/netbox/tests/dummy_plugin/api/views.py @@ -1,5 +1,5 @@ from rest_framework.viewsets import ModelViewSet -from extras.tests.dummy_plugin.models import DummyModel +from netbox.tests.dummy_plugin.models import DummyModel from .serializers import DummySerializer diff --git a/netbox/netbox/tests/dummy_plugin/data_backends.py b/netbox/netbox/tests/dummy_plugin/data_backends.py new file mode 100644 index 000000000..9b63e51c6 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/data_backends.py @@ -0,0 +1,18 @@ +from contextlib import contextmanager + +from netbox.data_backends import DataBackend + + +class DummyBackend(DataBackend): + name = 'dummy' + label = 'Dummy' + is_local = True + + @contextmanager + def fetch(self): + yield '/tmp' + + +backends = ( + DummyBackend, +) diff --git a/netbox/extras/tests/dummy_plugin/graphql.py b/netbox/netbox/tests/dummy_plugin/graphql.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/graphql.py rename to netbox/netbox/tests/dummy_plugin/graphql.py diff --git a/netbox/extras/tests/dummy_plugin/middleware.py b/netbox/netbox/tests/dummy_plugin/middleware.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/middleware.py rename to netbox/netbox/tests/dummy_plugin/middleware.py diff --git a/netbox/extras/tests/dummy_plugin/migrations/0001_initial.py b/netbox/netbox/tests/dummy_plugin/migrations/0001_initial.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/migrations/0001_initial.py rename to netbox/netbox/tests/dummy_plugin/migrations/0001_initial.py diff --git a/netbox/extras/tests/dummy_plugin/migrations/__init__.py b/netbox/netbox/tests/dummy_plugin/migrations/__init__.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/migrations/__init__.py rename to netbox/netbox/tests/dummy_plugin/migrations/__init__.py diff --git a/netbox/extras/tests/dummy_plugin/models.py b/netbox/netbox/tests/dummy_plugin/models.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/models.py rename to netbox/netbox/tests/dummy_plugin/models.py diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/netbox/tests/dummy_plugin/navigation.py similarity index 90% rename from netbox/extras/tests/dummy_plugin/navigation.py rename to netbox/netbox/tests/dummy_plugin/navigation.py index a9157b368..4e7bb4be8 100644 --- a/netbox/extras/tests/dummy_plugin/navigation.py +++ b/netbox/netbox/tests/dummy_plugin/navigation.py @@ -1,5 +1,5 @@ from django.utils.translation import gettext as _ -from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem +from netbox.plugins.navigation import PluginMenu, PluginMenuButton, PluginMenuItem items = ( diff --git a/netbox/extras/tests/dummy_plugin/preferences.py b/netbox/netbox/tests/dummy_plugin/preferences.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/preferences.py rename to netbox/netbox/tests/dummy_plugin/preferences.py diff --git a/netbox/extras/tests/dummy_plugin/search.py b/netbox/netbox/tests/dummy_plugin/search.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/search.py rename to netbox/netbox/tests/dummy_plugin/search.py diff --git a/netbox/netbox/tests/dummy_plugin/tables.py b/netbox/netbox/tests/dummy_plugin/tables.py new file mode 100644 index 000000000..0f1e823d7 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/tables.py @@ -0,0 +1,11 @@ +import django_tables2 as tables + +from dcim.tables import SiteTable +from utilities.tables import register_table_column + +mycol = tables.Column( + verbose_name='My column', + accessor=tables.A('description') +) + +register_table_column(mycol, 'foo', SiteTable) diff --git a/netbox/extras/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py similarity index 88% rename from netbox/extras/tests/dummy_plugin/template_content.py rename to netbox/netbox/tests/dummy_plugin/template_content.py index 364768a22..b63338f2f 100644 --- a/netbox/extras/tests/dummy_plugin/template_content.py +++ b/netbox/netbox/tests/dummy_plugin/template_content.py @@ -1,4 +1,4 @@ -from extras.plugins import PluginTemplateExtension +from netbox.plugins.templates import PluginTemplateExtension class SiteContent(PluginTemplateExtension): diff --git a/netbox/extras/tests/dummy_plugin/urls.py b/netbox/netbox/tests/dummy_plugin/urls.py similarity index 100% rename from netbox/extras/tests/dummy_plugin/urls.py rename to netbox/netbox/tests/dummy_plugin/urls.py diff --git a/netbox/extras/tests/dummy_plugin/views.py b/netbox/netbox/tests/dummy_plugin/views.py similarity index 88% rename from netbox/extras/tests/dummy_plugin/views.py rename to netbox/netbox/tests/dummy_plugin/views.py index 8713102c5..03a83b585 100644 --- a/netbox/extras/tests/dummy_plugin/views.py +++ b/netbox/netbox/tests/dummy_plugin/views.py @@ -4,6 +4,8 @@ from django.views.generic import View from dcim.models import Site from utilities.views import register_model_view from .models import DummyModel +# Trigger registration of custom column +from .tables import mycol class DummyModelsView(View): diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index 73f775bd7..bd07886e8 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -3,7 +3,7 @@ from django.test import override_settings from dcim.models import * from users.models import ObjectPermission -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ModelViewTestCase, create_tags @@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase): def _get_csv_data(self, csv_data): return '\n'.join(csv_data) + def test_invalid_headers(self): + """ + Test that import form validation fails when an unknown CSV header is present. + """ + self.add_permissions('dcim.add_region') + + csv_data = [ + 'name,slug,INVALIDHEADER', + 'Region 1,region-1,abc', + 'Region 2,region-2,def', + 'Region 3,region-3,ghi', + ] + data = { + 'format': ImportFormatChoices.CSV, + 'data': self._get_csv_data(csv_data), + 'csv_delimiter': CSVDelimiterChoices.AUTO, + } + + # Form validation should fail with invalid header present + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200) + self.assertEqual(Region.objects.count(), 0) + + # Correct the CSV header name + csv_data[0] = 'name,slug,description' + data['data'] = self._get_csv_data(csv_data) + + # Validation should succeed + self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302) + self.assertEqual(Region.objects.count(), 3) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_valid_tags(self): csv_data = ( @@ -30,6 +60,7 @@ class CSVImportTestCase(ModelViewTestCase): data = { 'format': ImportFormatChoices.CSV, 'data': self._get_csv_data(csv_data), + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission diff --git a/netbox/extras/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py similarity index 79% rename from netbox/extras/tests/test_plugins.py rename to netbox/netbox/tests/test_plugins.py index 42dde43fd..40bf8b0ea 100644 --- a/netbox/extras/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -5,22 +5,23 @@ from django.core.exceptions import ImproperlyConfigured from django.test import Client, TestCase, override_settings from django.urls import reverse -from extras.plugins import PluginMenu -from extras.tests.dummy_plugin import config as dummy_config -from extras.plugins.utils import get_plugin_config +from netbox.tests.dummy_plugin import config as dummy_config +from netbox.tests.dummy_plugin.data_backends import DummyBackend +from netbox.plugins.navigation import PluginMenu +from netbox.plugins.utils import get_plugin_config from netbox.graphql.schema import Query from netbox.registry import registry -@skipIf('extras.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") +@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") class PluginTest(TestCase): def test_config(self): - self.assertIn('extras.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) + self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) def test_models(self): - from extras.tests.dummy_plugin.models import DummyModel + from netbox.tests.dummy_plugin.models import DummyModel # Test saving an instance instance = DummyModel(name='Instance 1', number=100) @@ -92,10 +93,20 @@ class PluginTest(TestCase): """ Check that plugin TemplateExtensions are registered. """ - from extras.tests.dummy_plugin.template_content import SiteContent + from netbox.tests.dummy_plugin.template_content import SiteContent self.assertIn(SiteContent, registry['plugins']['template_extensions']['dcim.site']) + def test_registered_columns(self): + """ + Check that a plugin can register a custom column on a core model table. + """ + from dcim.models import Site + from dcim.tables import SiteTable + + table = SiteTable(Site.objects.all()) + self.assertIn('foo', table.columns.names()) + def test_user_preferences(self): """ Check that plugin UserPreferences are registered. @@ -109,15 +120,22 @@ class PluginTest(TestCase): """ Check that plugin middleware is registered. """ - self.assertIn('extras.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE) + self.assertIn('netbox.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE) + + def test_data_backends(self): + """ + Check registered data backends. + """ + self.assertIn('dummy', registry['data_backends']) + self.assertIs(registry['data_backends']['dummy'], DummyBackend) def test_queues(self): """ Check that plugin queues are registered with the accurate name. """ - self.assertIn('extras.tests.dummy_plugin.testing-low', settings.RQ_QUEUES) - self.assertIn('extras.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES) - self.assertIn('extras.tests.dummy_plugin.testing-high', settings.RQ_QUEUES) + self.assertIn('netbox.tests.dummy_plugin.testing-low', settings.RQ_QUEUES) + self.assertIn('netbox.tests.dummy_plugin.testing-medium', settings.RQ_QUEUES) + self.assertIn('netbox.tests.dummy_plugin.testing-high', settings.RQ_QUEUES) def test_min_version(self): """ @@ -170,17 +188,17 @@ class PluginTest(TestCase): """ Validate the registration and operation of plugin-provided GraphQL schemas. """ - from extras.tests.dummy_plugin.graphql import DummyQuery + from netbox.tests.dummy_plugin.graphql import DummyQuery self.assertIn(DummyQuery, registry['plugins']['graphql_schemas']) self.assertTrue(issubclass(Query, DummyQuery)) - @override_settings(PLUGINS_CONFIG={'extras.tests.dummy_plugin': {'foo': 123}}) + @override_settings(PLUGINS_CONFIG={'netbox.tests.dummy_plugin': {'foo': 123}}) def test_get_plugin_config(self): """ Validate that get_plugin_config() returns config parameters correctly. """ - plugin = 'extras.tests.dummy_plugin' + plugin = 'netbox.tests.dummy_plugin' self.assertEqual(get_plugin_config(plugin, 'foo'), 123) self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 595a9001f..6955426a8 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -6,10 +6,10 @@ from django.views.static import serve from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from account.views import LoginView, LogoutView -from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.api.views import APIRootView, StatusView from netbox.graphql.schema import schema from netbox.graphql.views import GraphQLView +from netbox.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx from .admin import admin_site diff --git a/netbox/netbox/utils.py b/netbox/netbox/utils.py new file mode 100644 index 000000000..f27d1b5f7 --- /dev/null +++ b/netbox/netbox/utils.py @@ -0,0 +1,26 @@ +from netbox.registry import registry + +__all__ = ( + 'get_data_backend_choices', + 'register_data_backend', +) + + +def get_data_backend_choices(): + return [ + (None, '---------'), + *[ + (name, cls.label) for name, cls in registry['data_backends'].items() + ] + ] + + +def register_data_backend(): + """ + Decorator for registering a DataBackend class. + """ + def _wrapper(cls): + registry['data_backends'][cls.name] = cls + return cls + + return _wrapper diff --git a/netbox/netbox/views/errors.py b/netbox/netbox/views/errors.py index a81d45cb5..a0f783ed6 100644 --- a/netbox/netbox/views/errors.py +++ b/netbox/netbox/views/errors.py @@ -9,9 +9,8 @@ from django.template.exceptions import TemplateDoesNotExist from django.views.decorators.csrf import requires_csrf_token from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found from django.views.generic import View -from sentry_sdk import capture_message -from extras.plugins.utils import get_installed_plugins +from netbox.plugins.utils import get_installed_plugins __all__ = ( 'handler_404', @@ -34,7 +33,9 @@ def handler_404(request, exception): """ Wrap Django's default 404 handler to enable Sentry reporting. """ - capture_message("Page not found", level="error") + if settings.SENTRY_ENABLED: + from sentry_sdk import capture_message + capture_message("Page not found", level="error") return page_not_found(request, exception) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index bef524bce..fbe3aa2ba 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,10 +3,11 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.fields import GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError -from django.db.models import ManyToManyField, ProtectedError +from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models.fields.reverse_related import ManyToManyRel from django.forms import HiddenInput, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse @@ -519,9 +520,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): model_field = self.queryset.model._meta.get_field(name) if isinstance(model_field, (ManyToManyField, ManyToManyRel)): m2m_fields[name] = model_field + elif isinstance(model_field, GenericRel): + # Ignore generic relations (these may be used for other purposes in the form) + continue else: model_fields[name] = model_field - except FieldDoesNotExist: # This form field is used to modify a field rather than set its value directly model_fields[name] = None @@ -795,14 +798,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): queryset = self.queryset.filter(pk__in=pk_list) deleted_count = queryset.count() try: - for obj in queryset: - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - obj.delete() + with transaction.atomic(): + for obj in queryset: + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + obj.delete() - except ProtectedError as e: - logger.info("Caught ProtectedError while attempting to delete objects") + except (ProtectedError, RestrictedError) as e: + logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py index a55f01509..d01c534bb 100644 --- a/netbox/netbox/views/generic/mixins.py +++ b/netbox/netbox/views/generic/mixins.py @@ -1,5 +1,6 @@ -from collections import defaultdict +import warnings +from netbox.constants import DEFAULT_ACTION_PERMISSIONS from utilities.permissions import get_permission_for_model __all__ = ( @@ -9,13 +10,15 @@ __all__ = ( class ActionsMixin: - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete') - action_perms = defaultdict(set, **{ - 'add': {'add'}, - 'import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - }) + """ + Maps action names to the set of required permissions for each. Object list views reference this mapping to + determine whether to render the applicable button for each action: The button will be rendered only if the user + possesses the specified permission(s). + + Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map + with custom actions, such as bulk_sync. + """ + actions = DEFAULT_ACTION_PERMISSIONS def get_permitted_actions(self, user, model=None): """ @@ -23,11 +26,43 @@ class ActionsMixin: """ model = model or self.queryset.model - return [ - action for action in self.actions if user.has_perms([ - get_permission_for_model(model, name) for name in self.action_perms[action] - ]) - ] + # TODO: Remove backward compatibility in Netbox v4.0 + # Determine how permissions are being mapped to actions for the view + if hasattr(self, 'action_perms'): + # Backward compatibility for <3.7 + permissions_map = self.action_perms + warnings.warn( + "Setting action_perms on views is deprecated and will be removed in NetBox v4.0. Use actions instead.", + DeprecationWarning + ) + elif type(self.actions) is dict: + # New actions format (3.7+) + permissions_map = self.actions + else: + # actions is still defined as a list or tuple (<3.7) but no custom mapping is defined; use the old + # default mapping + permissions_map = { + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + } + warnings.warn( + "View actions should be defined as a dictionary mapping. Support for the legacy list format will be " + "removed in NetBox v4.0.", + DeprecationWarning + ) + + # Resolve required permissions for each action + permitted_actions = [] + for action in self.actions: + required_permissions = [ + get_permission_for_model(model, name) for name in permissions_map.get(action, set()) + ] + if not required_permissions or user.has_perms(required_permissions): + permitted_actions.append(action) + + return permitted_actions class TableMixin: diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 99d8ff540..99508c9e3 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -1,9 +1,11 @@ import logging +from collections import defaultdict from copy import deepcopy from django.contrib import messages -from django.db import transaction -from django.db.models import ProtectedError +from django.db import router, transaction +from django.db.models import ProtectedError, RestrictedError +from django.db.models.deletion import Collector from django.shortcuts import redirect, render from django.urls import reverse from django.utils.html import escape @@ -320,6 +322,27 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') + def _get_dependent_objects(self, obj): + """ + Returns a dictionary mapping of dependent objects (organized by model) which will be deleted as a result of + deleting the requested object. + + Args: + obj: The object to return dependent objects for + """ + using = router.db_for_write(obj._meta.model) + collector = Collector(using=using) + collector.collect([obj]) + + # Compile a mapping of models to instances + dependent_objects = defaultdict(list) + for model, instance in collector.instances_with_model(): + # Omit the root object + if instance != obj: + dependent_objects[model].append(instance) + + return dict(dependent_objects) + # # Request handlers # @@ -333,6 +356,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) + dependent_objects = self._get_dependent_objects(obj) # If this is an HTMX request, return only the rendered deletion form as modal content if is_htmx(request): @@ -343,6 +367,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): 'object_type': self.queryset.model._meta.verbose_name, 'form': form, 'form_url': form_url, + 'dependent_objects': dependent_objects, **self.get_extra_context(request, obj), }) @@ -350,6 +375,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), + 'dependent_objects': dependent_objects, **self.get_extra_context(request, obj), }) @@ -374,8 +400,8 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() - except ProtectedError as e: - logger.info("Caught ProtectedError while attempting to delete object") + except (ProtectedError, RestrictedError) as e: + logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 2aa24b72c..84d1600e3 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index ffdd83285..9048a3286 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index b492e4d1d..7a3cd7859 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 84bfecae3..426302ea8 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 7f2400ed2..077c4bcc0 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/clipboard.ts b/netbox/project-static/src/clipboard.ts index 46ca5e36c..ddcb7b96e 100644 --- a/netbox/project-static/src/clipboard.ts +++ b/netbox/project-static/src/clipboard.ts @@ -2,7 +2,7 @@ import Clipboard from 'clipboard'; import { getElements } from './util'; export function initClipboard(): void { - for (const element of getElements('a.copy-content')) { + for (const element of getElements('.copy-content')) { new Clipboard(element); } } diff --git a/netbox/project-static/src/forms/scopeSelector.ts b/netbox/project-static/src/forms/scopeSelector.ts index 14ef972f8..f7b77f041 100644 --- a/netbox/project-static/src/forms/scopeSelector.ts +++ b/netbox/project-static/src/forms/scopeSelector.ts @@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = { const showHideMap: ShowHideMap = { vlangroup_add: 'vlangroup', vlangroup_edit: 'vlangroup', + vlangroup_bulk_edit: 'vlangroup', }; /** diff --git a/netbox/project-static/src/tables/interfaceTable.ts b/netbox/project-static/src/tables/interfaceTable.ts index 56a0ae754..70243cf41 100644 --- a/netbox/project-static/src/tables/interfaceTable.ts +++ b/netbox/project-static/src/tables/interfaceTable.ts @@ -141,9 +141,10 @@ class TableState { private virtualButton: ButtonState; /** - * Underlying DOM Table Caption Element. + * Instance of ButtonState for the 'show/hide virtual rows' button. */ - private caption: Nullable = null; + // @ts-expect-error null handling is performed in the constructor + private disconnectedButton: ButtonState; /** * All table rows in table @@ -166,9 +167,10 @@ class TableState { this.table, 'button.toggle-virtual', ); - - const caption = this.table.querySelector('caption'); - this.caption = caption; + const toggleDisconnectedButton = findFirstAdjacent( + this.table, + 'button.toggle-disconnected', + ); if (toggleEnabledButton === null) { throw new TableStateError("Table is missing a 'toggle-enabled' button.", table); @@ -182,10 +184,15 @@ class TableState { throw new TableStateError("Table is missing a 'toggle-virtual' button.", table); } + if (toggleDisconnectedButton === null) { + throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table); + } + // Attach event listeners to the buttons elements. toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this)); + toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this)); // Instantiate ButtonState for each button for state management. this.enabledButton = new ButtonState( @@ -200,6 +207,10 @@ class TableState { toggleVirtualButton, table.querySelectorAll('tr[data-type="virtual"]'), ); + this.disconnectedButton = new ButtonState( + toggleDisconnectedButton, + table.querySelectorAll('tr[data-connected="disconnected"]'), + ); } catch (err) { if (err instanceof TableStateError) { // This class is useless for tables that don't have toggle buttons. @@ -211,52 +222,6 @@ class TableState { } } - /** - * Get the table caption's text. - */ - private get captionText(): string { - if (this.caption !== null) { - return this.caption.innerText; - } - return ''; - } - - /** - * Set the table caption's text. - */ - private set captionText(value: string) { - if (this.caption !== null) { - this.caption.innerText = value; - } - } - - /** - * Update the table caption's text based on the state of each toggle button. - */ - private toggleCaption(): void { - const showEnabled = this.enabledButton.buttonState === 'show'; - const showDisabled = this.disabledButton.buttonState === 'show'; - const showVirtual = this.virtualButton.buttonState === 'show'; - - if (showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled Interfaces'; - } else if (showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Enabled & Disabled Interfaces'; - } else if (!showEnabled && showDisabled && !showVirtual) { - this.captionText = 'Showing Disabled Interfaces'; - } else if (!showEnabled && !showDisabled && !showVirtual) { - this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces'; - } else if (!showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Virtual Interfaces'; - } else if (showEnabled && !showDisabled && showVirtual) { - this.captionText = 'Showing Enabled & Virtual Interfaces'; - } else if (showEnabled && showDisabled && showVirtual) { - this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces'; - } else { - this.captionText = ''; - } - } - /** * When toggle buttons are clicked, reapply visability all rows and * pass the event to all button handlers @@ -272,7 +237,7 @@ class TableState { instance.enabledButton.handleClick(event); instance.disabledButton.handleClick(event); instance.virtualButton.handleClick(event); - instance.toggleCaption(); + instance.disconnectedButton.handleClick(event); } } diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 94fddc32c..a38633b5c 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -167,6 +167,12 @@ table td > .progress { } } +.alert { + code { + color: $gray-600; + } +} + span.profile-button .dropdown-menu { right: 0; left: auto; diff --git a/netbox/templates/account/token.html b/netbox/templates/account/token.html index d83e13ff5..57d1de3f4 100644 --- a/netbox/templates/account/token.html +++ b/netbox/templates/account/token.html @@ -15,11 +15,6 @@ {% block content %}
- {% if key and not settings.ALLOW_TOKEN_RETRIEVAL %} - - {% endif %}
{% trans "Token" %}
diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html index 7c9094d42..1ddb67bac 100644 --- a/netbox/templates/circuits/circuit_terminations_swap.html +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -4,14 +4,18 @@ {% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %} {% block message %} -

{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}

+

+ {% blocktrans trimmed %} + Swap these terminations for circuit {{ circuit }}? + {% endblocktrans %} +

  • {% trans "A side" %}: {% if termination_a %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {% else %} - {{ ''|placeholder }} + {% trans "None" %} {% endif %}
  • @@ -19,7 +23,7 @@ {% if termination_z %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {% else %} - {{ ''|placeholder }} + {% trans "None" %} {% endif %}
diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index b8b08baf0..407ee4042 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -29,6 +29,16 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Color" %} + + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} + +
diff --git a/netbox/templates/core/datasource.html b/netbox/templates/core/datasource.html index 369c395f8..51090b0c9 100644 --- a/netbox/templates/core/datasource.html +++ b/netbox/templates/core/datasource.html @@ -58,7 +58,7 @@ {% trans "URL" %} - {% if not object.is_local %} + {% if not object.type.is_local %} {{ object.source_url }} {% else %} {{ object.source_url }} diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index 1fe3862cd..deb651739 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -35,6 +35,12 @@ {% trans "Status" %} {% badge object.get_status_display object.get_status_color %} + {% if object.error %} + + {% trans "Error" %} + {{ object.error }} + + {% endif %} {% trans "Created By" %} {{ object.user|placeholder }} diff --git a/netbox/templates/dcim/bulk_disconnect.html b/netbox/templates/dcim/bulk_disconnect.html index ede0df357..555ed635b 100644 --- a/netbox/templates/dcim/bulk_disconnect.html +++ b/netbox/templates/dcim/bulk_disconnect.html @@ -6,7 +6,7 @@ {% block message %}

- {% blocktrans with count=selected_objects|length %} + {% blocktrans trimmed with count=selected_objects|length %} Are you sure you want to disconnect these {{ count }} {{ obj_type_plural }}? {% endblocktrans %}

diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 12000f09d..b004634bb 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -3,7 +3,7 @@ {% load i18n %} {% block title %} - {% blocktrans with object_type=object|meta:"verbose_name"|bettertitle %} + {% blocktrans trimmed with object_type=object|meta:"verbose_name"|bettertitle %} Cable Trace for {{ object_type }} {{ object }} {% endblocktrans %} {% endblock %} @@ -23,7 +23,15 @@
- {% if path.is_split %} + {% if path.is_split and path.get_asymmetric_nodes %} +

{% trans "Asymmetric Path" %}!

+

{% trans "The nodes below have no links and result in an asymmetric path" %}:

+
    + {% for next_node in path.get_asymmetric_nodes %} +
  • {{ next_node|linkify }}
  • + {% endfor %} +
+ {% elif path.is_split %}

{% trans "Path split" %}!

{% trans "Select a node below to continue" %}:

    @@ -51,10 +59,10 @@ {% trans "Total length" %} {% if total_length %} - {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} / - {{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %} + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} / + {{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %} {% else %} - {% trans "N/A" %} + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html new file mode 100644 index 000000000..a80dcfea8 --- /dev/null +++ b/netbox/templates/dcim/component_list.html @@ -0,0 +1,22 @@ +{% extends 'generic/object_list.html' %} +{% load buttons %} +{% load helpers %} +{% load i18n %} + +{% block bulk_buttons %} +
    + {% if 'bulk_edit' in actions %} + {% bulk_edit_button model query_params=request.GET %} + {% endif %} + {% if 'bulk_rename' in actions %} + {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} + + {% endwith %} + {% endif %} +
    + {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index aeab6e399..5fa6a3314 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -296,7 +296,7 @@ {% for leg in utilization.legs %} - {% trans "Leg" context "Leg of a power feed" %} {{ leg }} + {% trans "Leg" context "Leg of a power feed" %} {{ leg.name }} {{ leg.outlet_count }} {{ leg.allocated }} diff --git a/netbox/templates/dcim/device/inc/interface_table_controls.html b/netbox/templates/dcim/device/inc/interface_table_controls.html index 36605cd25..7868d99db 100644 --- a/netbox/templates/dcim/device/inc/interface_table_controls.html +++ b/netbox/templates/dcim/device/inc/interface_table_controls.html @@ -9,5 +9,6 @@ +
{% endblock extra_table_controls %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index cab46886b..8b3fe3097 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -2,6 +2,10 @@ {% load helpers %} {% load i18n %} +{% block table_controls %} + {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} +{% endblock table_controls %} + {% block bulk_delete_controls %} {{ block.super }} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} diff --git a/netbox/templates/dcim/devicebay_delete.html b/netbox/templates/dcim/devicebay_delete.html index 47e2ba545..9e54baa86 100644 --- a/netbox/templates/dcim/devicebay_delete.html +++ b/netbox/templates/dcim/devicebay_delete.html @@ -2,8 +2,14 @@ {% load form_helpers %} {% load i18n %} -{% block title %}{% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %}{% endblock %} +{% block title %} + {% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %} +{% endblock %} {% block message %} -

{% blocktrans %}Are you sure you want to delete this device bay from {{ devicebay.device }}?{% endblocktrans %}

+

+ {% blocktrans trimmed with device=devicebay.device %} + Are you sure you want to delete this device bay from {{ device }}? + {% endblocktrans %} +

{% endblock %} diff --git a/netbox/templates/dcim/devicebay_depopulate.html b/netbox/templates/dcim/devicebay_depopulate.html index a0c026800..b094f5993 100644 --- a/netbox/templates/dcim/devicebay_depopulate.html +++ b/netbox/templates/dcim/devicebay_depopulate.html @@ -3,14 +3,14 @@ {% load i18n %} {% block title %} - {% blocktrans with device=device_bay.installed_device %} + {% blocktrans trimmed with device=device_bay.installed_device %} Remove {{ device }} from {{ device_bay }}? {% endblocktrans %} {% endblock %} {% block message %}

- {% blocktrans with device=device_bay.installed_device %} + {% blocktrans trimmed with device=device_bay.installed_device %} Are you sure you want to remove {{ device }} from {{ device_bay }}? {% endblocktrans %}

diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 419ab7f00..35b089664 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -40,6 +40,10 @@ {% trans "Height (U" %}) {{ object.u_height|floatformat }} + + {% trans "Exclude From Utilization" %}) + {% checkmark object.exclude_from_utilization %} + {% trans "Full Depth" %} {% checkmark object.is_full_depth %} diff --git a/netbox/templates/dcim/devicetype/component_templates.html b/netbox/templates/dcim/devicetype/component_templates.html index a2dcb6c0e..9a5210762 100644 --- a/netbox/templates/dcim/devicetype/component_templates.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -27,7 +27,7 @@
diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html index 63cc1bb99..bb54a33f9 100644 --- a/netbox/templates/dcim/moduletype/component_templates.html +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -27,7 +27,7 @@
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index a974f9f93..9448ad3e5 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -13,7 +13,7 @@ {% block extra_controls %} {% if perms.dcim.add_device %} - + {% trans "Add Device" %} {% endif %} @@ -44,17 +44,6 @@ {% trans "Config Template" %} {{ object.config_template|linkify|placeholder }} - - - {% trans "NAPALM Driver" %} - - -
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index ce00f333c..9b791d0e2 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -73,7 +73,7 @@ {% endif %} {% else %} - {% trans "N/A" %} + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/dcim/rack/base.html b/netbox/templates/dcim/rack/base.html index 27ac284a2..2f4eb227c 100644 --- a/netbox/templates/dcim/rack/base.html +++ b/netbox/templates/dcim/rack/base.html @@ -1,7 +1,7 @@ {% extends 'generic/object.html' %} {% load i18n %} -{% block title %}{% blocktrans %}Rack {{ object }}{% endblocktrans %}{% endblock %} +{% block title %}{% trans "Rack" %} {{ object }}{% endblock %} {% block breadcrumbs %} {{ block.super }} diff --git a/netbox/templates/dcim/virtualchassis_add_member.html b/netbox/templates/dcim/virtualchassis_add_member.html index 6f9b24183..ceb2c71b3 100644 --- a/netbox/templates/dcim/virtualchassis_add_member.html +++ b/netbox/templates/dcim/virtualchassis_add_member.html @@ -2,7 +2,11 @@ {% load form_helpers %} {% load i18n %} -{% block title %}{% blocktrans %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblocktrans %}{% endblock %} +{% block title %} + {% blocktrans trimmed %} + Add New Member to Virtual Chassis {{ virtual_chassis }} + {% endblocktrans %} +{% endblock %} {% block content %}
diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index cfc3de2ec..b8f232fc2 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -4,7 +4,7 @@ {% load i18n %} {% block title %} - {% blocktrans with name=vc_form.instance %} + {% blocktrans trimmed with name=vc_form.instance %} Editing Virtual Chassis {{ name }} {% endblocktrans %} {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_remove_member.html b/netbox/templates/dcim/virtualchassis_remove_member.html index 520f3d862..363c2b195 100644 --- a/netbox/templates/dcim/virtualchassis_remove_member.html +++ b/netbox/templates/dcim/virtualchassis_remove_member.html @@ -6,7 +6,7 @@ {% block message %}

- {% blocktrans with name=device.virtual_chassis %} + {% blocktrans trimmed with name=device.virtual_chassis %} Are you sure you want to remove {{ device }} from virtual chassis {{ name }}? {% endblocktrans %}

diff --git a/netbox/templates/django/forms/widgets/checkbox.html b/netbox/templates/django/forms/widgets/checkbox.html new file mode 100644 index 000000000..bbe201a29 --- /dev/null +++ b/netbox/templates/django/forms/widgets/checkbox.html @@ -0,0 +1,6 @@ +{% comment %} + Include a hidden field of the same name to ensure that unchecked checkboxes + are always included in the submitted form data. +{% endcomment %} + +{% include "django/forms/widgets/input.html" %} diff --git a/netbox/templates/exceptions/import_error.html b/netbox/templates/exceptions/import_error.html index 70896328d..1996412e1 100644 --- a/netbox/templates/exceptions/import_error.html +++ b/netbox/templates/exceptions/import_error.html @@ -7,19 +7,20 @@

- {% blocktrans %} - Missing required packages. This installation of NetBox might be missing one or more required - Python packages. These packages are listed in requirements.txt and - local_requirements.txt, and are normally installed as part of the installation or upgrade process. - To verify installed packages, run pip freeze from the console and compare the output to the list of - required packages. + {% trans "Missing required packages" %}. + {% blocktrans trimmed %} + This installation of NetBox might be missing one or more required Python packages. These packages are listed in + requirements.txt and local_requirements.txt, and are normally installed as part of the + installation or upgrade process. To verify installed packages, run pip freeze from the console and + compare the output to the list of required packages. {% endblocktrans %}

- {% blocktrans %} - WSGI service not restarted after upgrade. If this installation has recently been upgraded, check - that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is running. + {% trans "WSGI service not restarted after upgrade" %}. + {% blocktrans trimmed %} + If this installation has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been + restarted. This ensures that the new code is running. {% endblocktrans %}

{% endblock message %} diff --git a/netbox/templates/exceptions/permission_error.html b/netbox/templates/exceptions/permission_error.html index 3df6ad5c8..778508117 100644 --- a/netbox/templates/exceptions/permission_error.html +++ b/netbox/templates/exceptions/permission_error.html @@ -7,10 +7,10 @@

- {% blocktrans with media_root=settings.MEDIA_ROOT %} - Insufficient write permission to the media root. The configured media root is - {{ media_root }}. Ensure that the user NetBox runs as has access to write files to all locations - within this path. + {% trans "Insufficient write permission to the media root" %}. + {% blocktrans trimmed with media_root=settings.MEDIA_ROOT %} + The configured media root is {{ media_root }}. Ensure that the user NetBox runs as has access to + write files to all locations within this path. {% endblocktrans %}

{% endblock message %} diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html index 5d82e4511..fdcbcbda0 100644 --- a/netbox/templates/exceptions/programming_error.html +++ b/netbox/templates/exceptions/programming_error.html @@ -7,18 +7,18 @@

- {% blocktrans %} - Database migrations missing. When upgrading to a new NetBox release, the upgrade script must be - run to apply any new database migrations. You can run migrations manually by executing - python3 manage.py migrate from the command line. + {% trans "Database migrations missing" %}. + {% blocktrans trimmed %} + When upgrading to a new NetBox release, the upgrade script must be run to apply any new database migrations. You + can run migrations manually by executing python3 manage.py migrate from the command line. {% endblocktrans %}

- {% blocktrans %} - Unsupported PostgreSQL version. Ensure that PostgreSQL version 12 or later is in use. You can - check this by connecting to the database using NetBox's credentials and issuing a query for - SELECT VERSION(). + {% trans "Unsupported PostgreSQL version" %}. + {% blocktrans trimmed %} + Ensure that PostgreSQL version 12 or later is in use. You can check this by connecting to the database using + NetBox's credentials and issuing a query for SELECT VERSION(). {% endblocktrans %}

{% endblock message %} diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html index 5937e842a..a880865c3 100644 --- a/netbox/templates/extras/configrevision.html +++ b/netbox/templates/extras/configrevision.html @@ -14,11 +14,11 @@
{% plugin_buttons object %} - {% if object.is_active and perms.extras.add_configrevision %} + {% if not object.pk or object.is_active and perms.extras.add_configrevision %} {% url 'extras:configrevision_add' as edit_url %} {% include "buttons/edit.html" with url=edit_url %} {% endif %} - {% if not object.is_active and perms.extras.delete_configrevision %} + {% if object.pk and not object.is_active and perms.extras.delete_configrevision %} {% delete_button object %} {% endif %}
@@ -28,6 +28,14 @@
{% endblock controls %} +{% block subtitle %} + {% if object.created %} +
+ {% trans "Created" %} {{ object.created|annotated_date }} +
+ {% endif %} +{% endblock subtitle %} + {% block content %}
@@ -143,6 +151,10 @@ {% trans "Custom validators" %} {{ object.data.CUSTOM_VALIDATORS|placeholder }} + + {% trans "Protection rules" %} + {{ object.data.PROTECTION_RULES|placeholder }} +
diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 85824e61b..95919b414 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -32,7 +32,7 @@ {{ object.group_name|placeholder }} - + {% trans "Description" %} {{ object.description|markdown|placeholder }} @@ -79,8 +79,12 @@ {{ object.weight }} - {% trans "UI Visibility" %} - {{ object.get_ui_visibility_display }} + {% trans "UI Visible" %} + {{ object.get_ui_visible_display }} + + + {% trans "UI Editable" %} + {{ object.get_ui_editable_display }} diff --git a/netbox/templates/extras/dashboard/reset.html b/netbox/templates/extras/dashboard/reset.html index ceb032c0d..b163cabb7 100644 --- a/netbox/templates/extras/dashboard/reset.html +++ b/netbox/templates/extras/dashboard/reset.html @@ -4,6 +4,14 @@ {% block title %}{% trans "Reset Dashboard" %}?{% endblock %} {% block message %} -

{% blocktrans %}This will remove all configured widgets and restore the default dashboard configuration.{% endblocktrans %}

-

{% blocktrans %}This change affects only your dashboard, and will not impact other users.{% endblocktrans %}

+

+ {% blocktrans trimmed %} + This will remove all configured widgets and restore the default dashboard configuration. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + This change affects only your dashboard, and will not impact other users. + {% endblocktrans %} +

{% endblock %} diff --git a/netbox/templates/extras/dashboard/widget.html b/netbox/templates/extras/dashboard/widget.html index 1559363d3..b8dec3de2 100644 --- a/netbox/templates/extras/dashboard/widget.html +++ b/netbox/templates/extras/dashboard/widget.html @@ -9,14 +9,16 @@ gs-id="{{ widget.id }}" >
-
+
+ > + +
+ > + +
{% if widget.title %} {{ widget.title }} diff --git a/netbox/templates/extras/dashboard/widgets/bookmarks.html b/netbox/templates/extras/dashboard/widgets/bookmarks.html index e8638d20e..80eb6238e 100644 --- a/netbox/templates/extras/dashboard/widgets/bookmarks.html +++ b/netbox/templates/extras/dashboard/widgets/bookmarks.html @@ -11,6 +11,6 @@ {% else %}

- {% blocktrans %}No bookmarks have been added yet.{% endblocktrans %} + {% trans "No bookmarks have been added yet." %}

{% endif %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index d681ecd75..63f2019ae 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -153,7 +153,11 @@ {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %} {% if related_changes_count > related_changes_table.rows|length %} {% endif %}
diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 07d6fcfd5..806279d20 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -8,11 +8,17 @@ {% if perms.extras.run_report %}
+ {% if not report.is_valid %} +
+ + {% trans "This report is invalid and cannot be run." %} +
+ {% endif %} {% csrf_token %} {% render_form form %}
- @@ -66,6 +67,7 @@ Context: {% render_field form.upload_file %} {% render_field form.format %} + {% render_field form.csv_delimiter %}
@@ -87,6 +89,7 @@ Context: {% render_field form.data_source %} {% render_field form.data_file %} {% render_field form.format %} + {% render_field form.csv_delimiter %}
@@ -177,7 +180,7 @@ Context: {% if field|widget_type == 'dateinput' %} {% trans "Format: YYYY-MM-DD" %} {% elif field|widget_type == 'checkboxinput' %} - {% trans "Specify \"true\" or \"false" %}" + {% trans "Specify true or false" %} {% endif %} @@ -189,11 +192,15 @@ Context:

- {% blocktrans %}Required fields must be specified for all objects.{% endblocktrans %} + {% blocktrans trimmed %} + Required fields must be specified for all objects. + {% endblocktrans %}

- {% blocktrans with example="vrf.rd" %}Related objects may be referenced by any unique attribute. For example, {{ example }} would identify a VRF by its route distinguisher.{% endblocktrans %} + {% blocktrans trimmed with example="vrf.rd" %} + Related objects may be referenced by any unique attribute. For example, {{ example }} would identify a VRF by its route distinguisher. + {% endblocktrans %}

{% endif %} diff --git a/netbox/templates/generic/bulk_remove.html b/netbox/templates/generic/bulk_remove.html index 2691fbd3a..0c76897db 100644 --- a/netbox/templates/generic/bulk_remove.html +++ b/netbox/templates/generic/bulk_remove.html @@ -12,13 +12,13 @@ diff --git a/netbox/templates/media_failure.html b/netbox/templates/media_failure.html index 264c58a16..8f357aea1 100644 --- a/netbox/templates/media_failure.html +++ b/netbox/templates/media_failure.html @@ -26,13 +26,13 @@

{% trans "Check the following" %}:

  • - {% blocktrans %} + {% blocktrans trimmed %} manage.py collectstatic was run during the most recent upgrade. This installs the most recent iteration of each static file into the static root path. {% endblocktrans %}
  • - {% blocktrans with docs_url="https://docs.netbox.dev/en/stable/installation/" %} + {% blocktrans trimmed with docs_url="https://docs.netbox.dev/en/stable/installation/" %} The HTTP service (e.g. nginx or Apache) is configured to serve files from the STATIC_ROOT path. Refer to the installation documentation for further guidance. {% endblocktrans %} @@ -44,7 +44,7 @@
  • - {% blocktrans %} + {% blocktrans trimmed %} The file {{ filename }} exists in the static root directory and is readable by the HTTP server. {% endblocktrans %} @@ -52,7 +52,7 @@

    {% url 'home' as home_url %} - {% blocktrans %} + {% blocktrans trimmed %} Click here to attempt loading NetBox again. {% endblocktrans %}

    diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index ef2036976..09a267c04 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -25,4 +25,11 @@ {% render_field form.priority %} {% render_field form.tags %}
  • + +
    +
    +
    {% trans "Custom Fields" %}
    +
    + {% render_custom_fields form %} +
    {% endblock %} diff --git a/netbox/templates/users/user.html b/netbox/templates/users/user.html index fe03f41ed..18c07c1cc 100644 --- a/netbox/templates/users/user.html +++ b/netbox/templates/users/user.html @@ -32,7 +32,7 @@ {% trans "Active" %} - {% checkmark object.active %} + {% checkmark object.is_active %} {% trans "Staff" %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 0116bbdff..38d3655d3 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -6,7 +6,7 @@ {% render_errors form %} {% block title %} - {% blocktrans %} + {% blocktrans trimmed %} Add Device to Cluster {{ cluster }} {% endblocktrans %} {% endblock %} diff --git a/netbox/templates/virtualization/virtualdisk.html b/netbox/templates/virtualization/virtualdisk.html new file mode 100644 index 000000000..821e58796 --- /dev/null +++ b/netbox/templates/virtualization/virtualdisk.html @@ -0,0 +1,59 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    {% trans "Virtual Disk" %}
    +
    + + + + + + + + + + + + + + + + + +
    {% trans "Virtual Machine" %}{{ object.virtual_machine|linkify }}
    {% trans "Name" %}{{ object.name }}
    {% trans "Size" %} + {% if object.size %} + {{ object.size }} {% trans "GB" context "Abbreviation for gigabyte" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    {% trans "Description" %}{{ object.description|placeholder }}
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 27f5ea114..873f18158 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -139,14 +139,16 @@ - {% trans "Disk Space" %} - - {% if object.disk %} - {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %} - {% else %} - {{ ''|placeholder }} - {% endif %} - + + {% trans "Disk Space" %} + + + {% if object.disk %} + {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    @@ -168,6 +170,26 @@ {% plugin_right_page object %}
    + +
    +
    +
    +
    {% trans "Virtual Disks" %}
    +
    + {% if perms.virtualization.add_virtualdisk %} + + {% endif %} +
    +
    +
    +
    {% plugin_full_width_page object %} diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 8a1d68ed6..a147ef944 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -16,9 +16,23 @@ {% endblock %} {% block extra_controls %} - {% if perms.virtualization.add_vminterface %} - - {% trans "Add Interfaces" %} - - {% endif %} + + + {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine/virtual_disks.html b/netbox/templates/virtualization/virtualmachine/virtual_disks.html new file mode 100644 index 000000000..a947f9824 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine/virtual_disks.html @@ -0,0 +1,14 @@ +{% extends 'generic/object_children.html' %} +{% load helpers %} +{% load i18n %} + +{% block bulk_edit_controls %} + {{ block.super }} + {% if 'bulk_rename' in actions %} + + {% endif %} +{% endblock bulk_edit_controls %} diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index bbb3ddab4..8c5e81256 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -15,6 +15,13 @@ {% endif %} + {% if perms.virtualization.add_virtualdisk %} +
  • + +
  • + {% endif %}
    {% endif %} diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index da0ad04bd..118cafd81 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -105,7 +105,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): model = ContactAssignment fields = [ 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'contact', 'role', 'priority', 'tags', - 'created', 'last_updated', + 'custom_fields', 'created', 'last_updated', ] @extend_schema_field(OpenApiTypes.OBJECT) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 39c86d80e..71a4961c3 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView from circuits.models import Circuit from dcim.models import Device, Rack, Site from ipam.models import IPAddress, Prefix, VLAN, VRF -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from tenancy import filtersets from tenancy.models import * from utilities.utils import count_related @@ -23,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenants # -class TenantGroupViewSet(NetBoxModelViewSet): +class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet): # Contacts # -class ContactGroupViewSet(NetBoxModelViewSet): +class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 0f4900f54..72f03e98a 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -3,11 +3,10 @@ from django.db.models import Q from django.utils.translation import gettext as _ from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * - __all__ = ( 'ContactAssignmentFilterSet', 'ContactFilterSet', @@ -81,7 +80,7 @@ class ContactFilterSet(NetBoxModelFilterSet): ) -class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet): +class ContactAssignmentFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label=_('Search'), diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 692b8963f..77e945542 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,8 +1,7 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ -from extras.utils import FeatureQuery +from core.models import ContentType from netbox.forms import NetBoxModelFilterSetForm from tenancy.choices import * from tenancy.models import * @@ -87,8 +86,7 @@ class ContactAssignmentFilterForm(NetBoxModelFilterSetForm): (_('Assignment'), ('content_type_id', 'group_id', 'contact_id', 'role_id', 'priority')), ) content_type_id = ContentTypeMultipleChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=FeatureQuery('contacts'), + queryset=ContentType.objects.with_feature('contacts'), required=False, label=_('Object type') ) diff --git a/netbox/tenancy/forms/model_forms.py b/netbox/tenancy/forms/model_forms.py index 72c030d84..9a53eba17 100644 --- a/netbox/tenancy/forms/model_forms.py +++ b/netbox/tenancy/forms/model_forms.py @@ -1,11 +1,9 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from extras.models import Tag from netbox.forms import NetBoxModelForm from tenancy.models import * -from utilities.forms import BootstrapMixin -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField +from utilities.forms.fields import CommentField, DynamicModelChoiceField, SlugField __all__ = ( 'ContactAssignmentForm', @@ -121,7 +119,7 @@ class ContactForm(NetBoxModelForm): } -class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): +class ContactAssignmentForm(NetBoxModelForm): group = DynamicModelChoiceField( label=_('Group'), queryset=ContactGroup.objects.all(), @@ -141,11 +139,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm): label=_('Role'), queryset=ContactRole.objects.all() ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False, - label=_('Tags') - ) class Meta: model = ContactAssignment diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index 727aa2eac..aab02b121 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,6 +1,6 @@ import graphene -from extras.graphql.mixins import TagsMixin +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from tenancy import filtersets, models from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType @@ -69,7 +69,7 @@ class ContactGroupType(OrganizationalObjectType): filterset_class = filtersets.ContactGroupFilterSet -class ContactAssignmentType(TagsMixin, BaseObjectType): +class ContactAssignmentType(CustomFieldsMixin, TagsMixin, BaseObjectType): class Meta: model = models.ContactAssignment diff --git a/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py new file mode 100644 index 000000000..ee6726822 --- /dev/null +++ b/netbox/tenancy/migrations/0012_contactassignment_custom_fields.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.6 on 2023-11-06 20:23 + +from django.db import migrations, models +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0011_contactassignment_tags'), + ] + + operations = [ + migrations.AddField( + model_name='contactassignment', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index e8327248d..e7f319051 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -1,11 +1,12 @@ 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.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ContentType from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel -from netbox.models.features import TagsMixin +from netbox.models.features import CustomFieldsMixin, TagsMixin from tenancy.choices import * __all__ = ( @@ -109,9 +110,9 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -class ContactAssignment(ChangeLoggedModel, TagsMixin): +class ContactAssignment(CustomFieldsMixin, TagsMixin, ChangeLoggedModel): content_type = models.ForeignKey( - to=ContentType, + to='contenttypes.ContentType', on_delete=models.CASCADE ) object_id = models.PositiveBigIntegerField() @@ -157,6 +158,15 @@ class ContactAssignment(ChangeLoggedModel, TagsMixin): def get_absolute_url(self): return reverse('tenancy:contact', args=[self.contact.pk]) + def clean(self): + super().clean() + + # Validate the assigned object type + if self.content_type not in ContentType.objects.with_feature('contacts'): + raise ValidationError( + _("Contacts cannot be assigned to this object type ({type}).").format(type=self.content_type) + ) + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.object diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index bee497608..56903d6b1 100644 --- a/netbox/tenancy/search.py +++ b/netbox/tenancy/search.py @@ -15,6 +15,7 @@ class ContactIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('group', 'title', 'phone', 'email', 'description') @register_search @@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -46,6 +49,7 @@ class TenantIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('group', 'description') @register_search @@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 2e7525481..a22c04569 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable): verbose_name=_('Role'), linkify=True ) + contact_group = tables.Column( + accessor=Accessor('contact__group'), + verbose_name=_('Group'), + linkify=True + ) contact_title = tables.Column( accessor=Accessor('contact__title'), verbose_name=_('Contact Title') @@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable): model = ContactAssignment fields = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone', - 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions' + 'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags', + 'actions' ) default_columns = ( 'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 76a86146c..27d5750ac 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,14 +2,9 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from circuits.models import Circuit -from dcim.models import Cable, Device, Location, PowerFeed, Rack, RackReservation, Site, VirtualDeviceContext -from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic -from utilities.utils import count_related +from utilities.utils import count_related, get_related_models from utilities.views import register_model_view, ViewTab -from virtualization.models import VirtualMachine, Cluster -from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * @@ -132,32 +127,8 @@ class TenantView(generic.ObjectView): def get_extra_context(self, request, instance): related_models = [ - # DCIM - (Site.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Rack.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Location.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Device.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cable.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (PowerFeed.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # IPAM - (VRF.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Prefix.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPRange.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (ASN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (VLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Circuits - (Circuit.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Virtualization - (VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (Cluster.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - # Wireless - (WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), - (WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance), 'tenant_id'), + (model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id') + for model, field in get_related_models(Tenant) ] return { @@ -386,7 +357,12 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable - actions = ('export', 'bulk_edit', 'bulk_delete') + actions = { + 'import': {'add'}, + 'export': {'view'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + } @register_model_view(ContactAssignment, 'edit') diff --git a/netbox/translations/en/LC_MESSAGES/django.po b/netbox/translations/en/LC_MESSAGES/django.po new file mode 100644 index 000000000..b04e843f2 --- /dev/null +++ b/netbox/translations/en/LC_MESSAGES/django.po @@ -0,0 +1,12322 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-30 17:19+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: account/tables.py:27 templates/account/token.html:23 +#: templates/users/token.html:18 users/forms/bulk_import.py:41 +#: users/forms/model_forms.py:113 +msgid "Key" +msgstr "" + +#: account/tables.py:31 users/forms/filtersets.py:135 +msgid "Write Enabled" +msgstr "" + +#: account/tables.py:34 core/tables/jobs.py:28 extras/choices.py:124 +#: extras/tables/tables.py:469 templates/account/token.html:44 +#: templates/core/job.html:52 templates/extras/configrevision.html:34 +#: templates/extras/configrevision_restore.html:12 +#: templates/extras/htmx/report_result.html:11 +#: templates/extras/htmx/script_result.html:12 +#: templates/extras/journalentry.html:25 templates/generic/object.html:48 +#: templates/users/token.html:36 +msgid "Created" +msgstr "" + +#: account/tables.py:37 templates/account/token.html:48 +#: templates/users/token.html:40 users/forms/bulk_edit.py:97 +#: users/forms/filtersets.py:139 +msgid "Expires" +msgstr "" + +#: account/tables.py:40 users/forms/filtersets.py:144 +msgid "Last Used" +msgstr "" + +#: account/tables.py:43 templates/account/token.html:56 +#: templates/users/token.html:48 users/forms/bulk_edit.py:102 +#: users/forms/model_forms.py:125 +msgid "Allowed IPs" +msgstr "" + +#: circuits/choices.py:21 dcim/choices.py:20 dcim/choices.py:102 +#: dcim/choices.py:174 dcim/choices.py:220 dcim/choices.py:1419 +#: dcim/choices.py:1495 dcim/choices.py:1545 virtualization/choices.py:20 +#: virtualization/choices.py:45 +msgid "Planned" +msgstr "" + +#: circuits/choices.py:22 netbox/navigation/menu.py:271 +msgid "Provisioning" +msgstr "" + +#: circuits/choices.py:23 dcim/choices.py:22 dcim/choices.py:103 +#: dcim/choices.py:173 dcim/choices.py:219 dcim/choices.py:1494 +#: dcim/choices.py:1544 extras/tables/tables.py:375 ipam/choices.py:31 +#: ipam/choices.py:49 ipam/choices.py:69 ipam/choices.py:154 +#: templates/extras/configcontext.html:26 templates/users/user.html:34 +#: users/forms/bulk_edit.py:36 virtualization/choices.py:22 +#: virtualization/choices.py:44 wireless/choices.py:25 +msgid "Active" +msgstr "" + +#: circuits/choices.py:24 dcim/choices.py:172 dcim/choices.py:218 +#: dcim/choices.py:1493 dcim/choices.py:1546 virtualization/choices.py:24 +#: virtualization/choices.py:43 +msgid "Offline" +msgstr "" + +#: circuits/choices.py:25 +msgid "Deprovisioning" +msgstr "" + +#: circuits/choices.py:26 +msgid "Decommissioned" +msgstr "" + +#: circuits/filtersets.py:29 circuits/filtersets.py:182 dcim/filtersets.py:118 +#: dcim/filtersets.py:179 dcim/filtersets.py:254 dcim/filtersets.py:362 +#: dcim/filtersets.py:873 dcim/filtersets.py:1179 dcim/filtersets.py:1674 +#: dcim/filtersets.py:1847 dcim/filtersets.py:1904 ipam/filtersets.py:304 +#: ipam/filtersets.py:891 ipam/filtersets.py:1122 +#: virtualization/filtersets.py:43 virtualization/filtersets.py:169 +msgid "Region (ID)" +msgstr "" + +#: circuits/filtersets.py:36 circuits/filtersets.py:189 dcim/filtersets.py:124 +#: dcim/filtersets.py:186 dcim/filtersets.py:261 dcim/filtersets.py:369 +#: dcim/filtersets.py:880 dcim/filtersets.py:1186 dcim/filtersets.py:1681 +#: dcim/filtersets.py:1854 dcim/filtersets.py:1911 extras/filtersets.py:383 +#: ipam/filtersets.py:311 ipam/filtersets.py:898 ipam/filtersets.py:1117 +#: virtualization/filtersets.py:50 virtualization/filtersets.py:176 +msgid "Region (slug)" +msgstr "" + +#: circuits/filtersets.py:42 circuits/filtersets.py:195 dcim/filtersets.py:192 +#: dcim/filtersets.py:267 dcim/filtersets.py:375 dcim/filtersets.py:886 +#: dcim/filtersets.py:1192 dcim/filtersets.py:1687 dcim/filtersets.py:1860 +#: dcim/filtersets.py:1917 ipam/filtersets.py:317 ipam/filtersets.py:904 +#: virtualization/filtersets.py:56 virtualization/filtersets.py:182 +msgid "Site group (ID)" +msgstr "" + +#: circuits/filtersets.py:49 circuits/filtersets.py:202 dcim/filtersets.py:199 +#: dcim/filtersets.py:274 dcim/filtersets.py:382 dcim/filtersets.py:893 +#: dcim/filtersets.py:1199 dcim/filtersets.py:1694 dcim/filtersets.py:1867 +#: dcim/filtersets.py:1924 extras/filtersets.py:389 ipam/filtersets.py:324 +#: ipam/filtersets.py:911 virtualization/filtersets.py:63 +#: virtualization/filtersets.py:189 +msgid "Site group (slug)" +msgstr "" + +#: circuits/filtersets.py:54 circuits/forms/bulk_import.py:117 +#: circuits/forms/filtersets.py:47 circuits/forms/filtersets.py:170 +#: circuits/forms/model_forms.py:137 dcim/forms/bulk_edit.py:166 +#: dcim/forms/bulk_edit.py:238 dcim/forms/bulk_edit.py:570 +#: dcim/forms/bulk_edit.py:763 dcim/forms/bulk_import.py:130 +#: dcim/forms/bulk_import.py:176 dcim/forms/bulk_import.py:249 +#: dcim/forms/bulk_import.py:477 dcim/forms/bulk_import.py:1239 +#: dcim/forms/bulk_import.py:1267 dcim/forms/filtersets.py:83 +#: dcim/forms/filtersets.py:215 dcim/forms/filtersets.py:261 +#: dcim/forms/filtersets.py:370 dcim/forms/filtersets.py:673 +#: dcim/forms/filtersets.py:903 dcim/forms/filtersets.py:927 +#: dcim/forms/filtersets.py:1016 dcim/forms/filtersets.py:1054 +#: dcim/forms/filtersets.py:1459 dcim/forms/filtersets.py:1483 +#: dcim/forms/filtersets.py:1507 dcim/forms/model_forms.py:138 +#: dcim/forms/model_forms.py:167 dcim/forms/model_forms.py:211 +#: dcim/forms/model_forms.py:397 dcim/forms/model_forms.py:629 +#: dcim/forms/object_create.py:357 dcim/tables/devices.py:186 +#: dcim/tables/power.py:26 dcim/tables/racks.py:62 dcim/tables/racks.py:138 +#: dcim/tables/sites.py:129 extras/filtersets.py:399 +#: ipam/forms/bulk_edit.py:217 ipam/forms/bulk_edit.py:271 +#: ipam/forms/bulk_edit.py:449 ipam/forms/bulk_edit.py:521 +#: ipam/forms/bulk_import.py:173 ipam/forms/bulk_import.py:440 +#: ipam/forms/filtersets.py:156 ipam/forms/filtersets.py:230 +#: ipam/forms/filtersets.py:420 ipam/forms/filtersets.py:472 +#: ipam/forms/filtersets.py:585 ipam/forms/model_forms.py:208 +#: ipam/forms/model_forms.py:550 ipam/forms/model_forms.py:642 +#: ipam/tables/ip.py:244 ipam/tables/vlans.py:114 ipam/tables/vlans.py:216 +#: templates/circuits/circuittermination_edit.html:20 +#: templates/circuits/inc/circuit_termination.html:33 +#: templates/dcim/device.html:30 templates/dcim/inc/cable_termination.html:8 +#: templates/dcim/inc/cable_termination.html:33 templates/dcim/location.html:40 +#: templates/dcim/powerpanel.html:23 templates/dcim/rack.html:18 +#: templates/dcim/rackreservation.html:25 templates/dcim/site.html:26 +#: templates/ipam/prefix.html:48 templates/ipam/vlan.html:17 +#: templates/ipam/vlan_edit.html:40 templates/virtualization/cluster.html:45 +#: templates/virtualization/virtualmachine.html:96 +#: virtualization/forms/bulk_edit.py:88 virtualization/forms/bulk_edit.py:97 +#: virtualization/forms/bulk_edit.py:106 virtualization/forms/bulk_edit.py:121 +#: virtualization/forms/bulk_import.py:58 +#: virtualization/forms/bulk_import.py:84 virtualization/forms/filtersets.py:75 +#: virtualization/forms/filtersets.py:141 +#: virtualization/forms/model_forms.py:73 +#: virtualization/forms/model_forms.py:106 +#: virtualization/forms/model_forms.py:173 virtualization/tables/clusters.py:77 +#: virtualization/tables/virtualmachines.py:51 wireless/forms/model_forms.py:77 +#: wireless/forms/model_forms.py:117 +msgid "Site" +msgstr "" + +#: circuits/filtersets.py:60 circuits/filtersets.py:213 +#: circuits/filtersets.py:250 dcim/filtersets.py:209 dcim/filtersets.py:284 +#: dcim/filtersets.py:356 extras/filtersets.py:405 ipam/filtersets.py:215 +#: ipam/filtersets.py:334 ipam/filtersets.py:921 ipam/filtersets.py:1127 +#: virtualization/filtersets.py:73 virtualization/filtersets.py:199 +msgid "Site (slug)" +msgstr "" + +#: circuits/filtersets.py:65 +msgid "ASN (ID)" +msgstr "" + +#: circuits/filtersets.py:86 circuits/filtersets.py:112 +#: circuits/filtersets.py:146 +msgid "Provider (ID)" +msgstr "" + +#: circuits/filtersets.py:92 circuits/filtersets.py:118 +#: circuits/filtersets.py:152 +msgid "Provider (slug)" +msgstr "" + +#: circuits/filtersets.py:157 +msgid "Provider account (ID)" +msgstr "" + +#: circuits/filtersets.py:162 +msgid "Provider network (ID)" +msgstr "" + +#: circuits/filtersets.py:166 +msgid "Circuit type (ID)" +msgstr "" + +#: circuits/filtersets.py:172 +msgid "Circuit type (slug)" +msgstr "" + +#: circuits/filtersets.py:207 circuits/filtersets.py:244 dcim/filtersets.py:203 +#: dcim/filtersets.py:278 dcim/filtersets.py:350 dcim/filtersets.py:897 +#: dcim/filtersets.py:1204 dcim/filtersets.py:1699 dcim/filtersets.py:1871 +#: dcim/filtersets.py:1929 ipam/filtersets.py:209 ipam/filtersets.py:328 +#: ipam/filtersets.py:915 ipam/filtersets.py:1132 +#: virtualization/filtersets.py:67 virtualization/filtersets.py:193 +msgid "Site (ID)" +msgstr "" + +#: circuits/filtersets.py:236 core/filtersets.py:72 dcim/filtersets.py:631 +#: dcim/filtersets.py:1173 dcim/filtersets.py:1975 extras/filtersets.py:40 +#: extras/filtersets.py:69 extras/filtersets.py:108 extras/filtersets.py:137 +#: extras/filtersets.py:164 extras/filtersets.py:195 extras/filtersets.py:264 +#: extras/filtersets.py:312 extras/filtersets.py:372 extras/filtersets.py:531 +#: extras/filtersets.py:573 extras/filtersets.py:614 extras/filtersets.py:637 +#: ipam/forms/model_forms.py:432 netbox/filtersets.py:275 +#: netbox/forms/__init__.py:23 netbox/forms/base.py:151 +#: templates/htmx/object_selector.html:28 templates/inc/filter_list.html:53 +#: templates/ipam/ipaddress_assign.html:32 templates/search.html:7 +#: templates/search.html:26 tenancy/filtersets.py:87 users/filtersets.py:21 +#: users/filtersets.py:37 users/filtersets.py:69 users/filtersets.py:117 +#: utilities/forms/forms.py:99 +msgid "Search" +msgstr "" + +#: circuits/filtersets.py:240 circuits/forms/bulk_edit.py:167 +#: circuits/forms/model_forms.py:110 circuits/forms/model_forms.py:132 +#: dcim/forms/connections.py:66 templates/circuits/circuit.html:15 +#: templates/dcim/inc/cable_termination.html:55 +#: templates/dcim/trace/circuit.html:4 +msgid "Circuit" +msgstr "" + +#: circuits/filtersets.py:254 +msgid "ProviderNetwork (ID)" +msgstr "" + +#: circuits/forms/bulk_edit.py:25 circuits/forms/filtersets.py:56 +#: circuits/forms/model_forms.py:26 circuits/tables/providers.py:33 +#: dcim/forms/bulk_edit.py:126 dcim/forms/filtersets.py:185 +#: dcim/forms/model_forms.py:126 dcim/tables/sites.py:94 +#: ipam/models/asns.py:126 ipam/tables/asn.py:27 ipam/views.py:221 +#: netbox/navigation/menu.py:160 netbox/navigation/menu.py:163 +#: templates/circuits/provider.html:24 +msgid "ASNs" +msgstr "" + +#: circuits/forms/bulk_edit.py:29 circuits/forms/bulk_edit.py:51 +#: circuits/forms/bulk_edit.py:78 circuits/forms/bulk_edit.py:99 +#: circuits/forms/bulk_edit.py:159 core/forms/bulk_edit.py:27 +#: dcim/forms/bulk_create.py:35 dcim/forms/bulk_edit.py:71 +#: dcim/forms/bulk_edit.py:90 dcim/forms/bulk_edit.py:149 +#: dcim/forms/bulk_edit.py:190 dcim/forms/bulk_edit.py:208 +#: dcim/forms/bulk_edit.py:336 dcim/forms/bulk_edit.py:371 +#: dcim/forms/bulk_edit.py:386 dcim/forms/bulk_edit.py:445 +#: dcim/forms/bulk_edit.py:484 dcim/forms/bulk_edit.py:514 +#: dcim/forms/bulk_edit.py:538 dcim/forms/bulk_edit.py:608 +#: dcim/forms/bulk_edit.py:657 dcim/forms/bulk_edit.py:709 +#: dcim/forms/bulk_edit.py:732 dcim/forms/bulk_edit.py:780 +#: dcim/forms/bulk_edit.py:850 dcim/forms/bulk_edit.py:903 +#: dcim/forms/bulk_edit.py:938 dcim/forms/bulk_edit.py:978 +#: dcim/forms/bulk_edit.py:1022 dcim/forms/bulk_edit.py:1067 +#: dcim/forms/bulk_edit.py:1094 dcim/forms/bulk_edit.py:1112 +#: dcim/forms/bulk_edit.py:1130 dcim/forms/bulk_edit.py:1148 +#: dcim/forms/bulk_edit.py:1566 extras/forms/bulk_edit.py:35 +#: extras/forms/bulk_edit.py:118 extras/forms/bulk_edit.py:147 +#: extras/forms/bulk_edit.py:242 extras/forms/bulk_edit.py:266 +#: extras/forms/bulk_edit.py:280 extras/tables/tables.py:78 +#: ipam/forms/bulk_edit.py:52 ipam/forms/bulk_edit.py:72 +#: ipam/forms/bulk_edit.py:92 ipam/forms/bulk_edit.py:116 +#: ipam/forms/bulk_edit.py:145 ipam/forms/bulk_edit.py:174 +#: ipam/forms/bulk_edit.py:193 ipam/forms/bulk_edit.py:262 +#: ipam/forms/bulk_edit.py:306 ipam/forms/bulk_edit.py:354 +#: ipam/forms/bulk_edit.py:397 ipam/forms/bulk_edit.py:425 +#: ipam/forms/bulk_edit.py:553 ipam/forms/bulk_edit.py:584 +#: ipam/forms/bulk_edit.py:613 templates/account/token.html:36 +#: templates/circuits/circuit.html:60 templates/circuits/circuittype.html:29 +#: templates/circuits/inc/circuit_termination.html:115 +#: templates/circuits/provider.html:34 +#: templates/circuits/providernetwork.html:35 templates/core/datasource.html:55 +#: templates/dcim/cable.html:37 templates/dcim/consoleport.html:47 +#: templates/dcim/consoleserverport.html:47 templates/dcim/device.html:113 +#: templates/dcim/devicebay.html:35 templates/dcim/devicerole.html:33 +#: templates/dcim/devicetype.html:36 templates/dcim/frontport.html:61 +#: templates/dcim/interface.html:70 templates/dcim/inventoryitem.html:61 +#: templates/dcim/inventoryitemrole.html:23 templates/dcim/location.html:36 +#: templates/dcim/manufacturer.html:43 templates/dcim/module.html:71 +#: templates/dcim/modulebay.html:39 templates/dcim/moduletype.html:27 +#: templates/dcim/platform.html:36 templates/dcim/powerfeed.html:43 +#: templates/dcim/poweroutlet.html:43 templates/dcim/powerpanel.html:31 +#: templates/dcim/powerport.html:43 templates/dcim/rack.html:61 +#: templates/dcim/rackreservation.html:69 templates/dcim/rackrole.html:29 +#: templates/dcim/rearport.html:57 templates/dcim/region.html:34 +#: templates/dcim/site.html:73 templates/dcim/sitegroup.html:34 +#: templates/dcim/virtualchassis.html:32 +#: templates/extras/admin/plugins_list.html:26 +#: templates/extras/configcontext.html:22 +#: templates/extras/configtemplate.html:18 templates/extras/customfield.html:35 +#: templates/extras/dashboard/widget_add.html:14 +#: templates/extras/exporttemplate.html:25 templates/extras/report_list.html:47 +#: templates/extras/savedfilter.html:18 templates/extras/script_list.html:53 +#: templates/extras/tag.html:23 templates/generic/bulk_import.html:118 +#: templates/ipam/aggregate.html:44 templates/ipam/asn.html:43 +#: templates/ipam/asnrange.html:39 templates/ipam/fhrpgroup.html:35 +#: templates/ipam/ipaddress.html:58 templates/ipam/iprange.html:70 +#: templates/ipam/l2vpn.html:27 templates/ipam/prefix.html:82 +#: templates/ipam/rir.html:29 templates/ipam/role.html:29 +#: templates/ipam/routetarget.html:22 templates/ipam/service.html:53 +#: templates/ipam/servicetemplate.html:28 templates/ipam/vlan.html:65 +#: templates/ipam/vlangroup.html:35 templates/ipam/vrf.html:36 +#: templates/tenancy/contact.html:68 templates/tenancy/contactgroup.html:28 +#: templates/tenancy/contactrole.html:23 templates/tenancy/tenant.html:25 +#: templates/tenancy/tenantgroup.html:36 +#: templates/users/objectpermission.html:22 templates/users/token.html:28 +#: templates/virtualization/cluster.html:28 +#: templates/virtualization/clustergroup.html:29 +#: templates/virtualization/clustertype.html:29 +#: templates/virtualization/virtualmachine.html:34 +#: templates/virtualization/vminterface.html:54 +#: templates/wireless/wirelesslan.html:27 +#: templates/wireless/wirelesslangroup.html:34 +#: templates/wireless/wirelesslink.html:37 tenancy/forms/bulk_edit.py:31 +#: tenancy/forms/bulk_edit.py:79 tenancy/forms/bulk_edit.py:121 +#: users/forms/bulk_edit.py:62 users/forms/bulk_edit.py:92 +#: virtualization/forms/bulk_edit.py:29 virtualization/forms/bulk_edit.py:43 +#: virtualization/forms/bulk_edit.py:174 virtualization/forms/bulk_edit.py:225 +#: wireless/forms/bulk_edit.py:28 wireless/forms/bulk_edit.py:81 +#: wireless/forms/bulk_edit.py:128 +msgid "Description" +msgstr "" + +#: circuits/forms/bulk_edit.py:46 circuits/forms/bulk_edit.py:68 +#: circuits/forms/bulk_edit.py:118 circuits/forms/bulk_import.py:35 +#: circuits/forms/bulk_import.py:50 circuits/forms/bulk_import.py:76 +#: circuits/forms/filtersets.py:70 circuits/forms/filtersets.py:88 +#: circuits/forms/filtersets.py:116 circuits/forms/filtersets.py:130 +#: circuits/forms/model_forms.py:32 circuits/forms/model_forms.py:44 +#: circuits/forms/model_forms.py:58 circuits/forms/model_forms.py:92 +#: circuits/tables/circuits.py:55 circuits/tables/providers.py:72 +#: circuits/tables/providers.py:103 templates/circuits/circuit.html:19 +#: templates/circuits/provider.html:20 +#: templates/circuits/provideraccount.html:21 +#: templates/circuits/providernetwork.html:23 +#: templates/dcim/inc/cable_termination.html:51 +msgid "Provider" +msgstr "" + +#: circuits/forms/bulk_edit.py:75 circuits/forms/filtersets.py:91 +#: templates/circuits/providernetwork.html:31 +msgid "Service ID" +msgstr "" + +#: circuits/forms/bulk_edit.py:95 circuits/forms/filtersets.py:107 +#: dcim/forms/bulk_edit.py:204 dcim/forms/bulk_edit.py:500 +#: dcim/forms/bulk_edit.py:694 dcim/forms/bulk_edit.py:1063 +#: dcim/forms/bulk_edit.py:1090 dcim/forms/bulk_edit.py:1562 +#: dcim/forms/filtersets.py:970 dcim/forms/filtersets.py:1344 +#: dcim/forms/filtersets.py:1365 dcim/tables/devices.py:700 +#: dcim/tables/devices.py:760 dcim/tables/devices.py:983 +#: dcim/tables/devicetypes.py:245 dcim/tables/devicetypes.py:260 +#: dcim/tables/racks.py:32 extras/forms/bulk_edit.py:238 +#: extras/tables/tables.py:323 templates/circuits/circuittype.html:33 +#: templates/dcim/cable.html:41 templates/dcim/devicerole.html:37 +#: templates/dcim/frontport.html:43 templates/dcim/inventoryitemrole.html:27 +#: templates/dcim/rackrole.html:33 templates/dcim/rearport.html:43 +#: templates/extras/tag.html:29 +msgid "Color" +msgstr "" + +#: circuits/forms/bulk_edit.py:113 circuits/forms/bulk_import.py:89 +#: circuits/forms/filtersets.py:125 core/forms/bulk_edit.py:17 +#: core/forms/filtersets.py:30 core/tables/data.py:20 core/tables/jobs.py:18 +#: dcim/forms/bulk_edit.py:281 dcim/forms/bulk_edit.py:672 +#: dcim/forms/bulk_edit.py:811 dcim/forms/bulk_edit.py:879 +#: dcim/forms/bulk_edit.py:898 dcim/forms/bulk_edit.py:921 +#: dcim/forms/bulk_edit.py:963 dcim/forms/bulk_edit.py:1007 +#: dcim/forms/bulk_edit.py:1058 dcim/forms/bulk_edit.py:1085 +#: dcim/forms/bulk_import.py:206 dcim/forms/bulk_import.py:645 +#: dcim/forms/bulk_import.py:671 dcim/forms/bulk_import.py:697 +#: dcim/forms/bulk_import.py:717 dcim/forms/bulk_import.py:800 +#: dcim/forms/bulk_import.py:890 dcim/forms/bulk_import.py:932 +#: dcim/forms/bulk_import.py:1145 dcim/forms/bulk_import.py:1304 +#: dcim/forms/filtersets.py:283 dcim/forms/filtersets.py:860 +#: dcim/forms/filtersets.py:960 dcim/forms/filtersets.py:1080 +#: dcim/forms/filtersets.py:1150 dcim/forms/filtersets.py:1172 +#: dcim/forms/filtersets.py:1194 dcim/forms/filtersets.py:1211 +#: dcim/forms/filtersets.py:1244 dcim/forms/filtersets.py:1339 +#: dcim/forms/filtersets.py:1360 dcim/forms/object_import.py:89 +#: dcim/forms/object_import.py:118 dcim/forms/object_import.py:150 +#: dcim/tables/devices.py:211 dcim/tables/devices.py:816 +#: dcim/tables/power.py:77 extras/forms/bulk_import.py:37 +#: extras/tables/tables.py:345 extras/tables/tables.py:443 +#: ipam/forms/bulk_edit.py:603 ipam/forms/bulk_import.py:524 +#: ipam/forms/filtersets.py:537 netbox/tables/tables.py:225 +#: templates/circuits/circuit.html:31 templates/core/datasource.html:39 +#: templates/dcim/cable.html:16 templates/dcim/consoleport.html:39 +#: templates/dcim/consoleserverport.html:39 templates/dcim/frontport.html:39 +#: templates/dcim/interface.html:47 templates/dcim/interface.html:171 +#: templates/dcim/interface.html:319 templates/dcim/powerfeed.html:35 +#: templates/dcim/poweroutlet.html:39 templates/dcim/powerport.html:39 +#: templates/dcim/rack.html:88 templates/dcim/rearport.html:39 +#: templates/ipam/l2vpn.html:23 templates/virtualization/cluster.html:20 +#: templates/wireless/inc/authentication_attrs.html:9 +#: templates/wireless/inc/wirelesslink_interface.html:14 +#: virtualization/forms/bulk_edit.py:57 virtualization/forms/bulk_import.py:40 +#: virtualization/forms/filtersets.py:50 virtualization/forms/model_forms.py:64 +#: virtualization/tables/clusters.py:66 +msgid "Type" +msgstr "" + +#: circuits/forms/bulk_edit.py:123 circuits/forms/bulk_import.py:82 +#: circuits/forms/filtersets.py:138 circuits/forms/model_forms.py:97 +msgid "Provider account" +msgstr "" + +#: circuits/forms/bulk_edit.py:131 circuits/forms/bulk_import.py:95 +#: circuits/forms/filtersets.py:149 core/forms/filtersets.py:35 +#: core/forms/filtersets.py:76 core/tables/data.py:23 core/tables/jobs.py:25 +#: dcim/forms/bulk_edit.py:104 dcim/forms/bulk_edit.py:179 +#: dcim/forms/bulk_edit.py:260 dcim/forms/bulk_edit.py:593 +#: dcim/forms/bulk_edit.py:646 dcim/forms/bulk_edit.py:678 +#: dcim/forms/bulk_edit.py:805 dcim/forms/bulk_edit.py:1585 +#: dcim/forms/bulk_import.py:87 dcim/forms/bulk_import.py:146 +#: dcim/forms/bulk_import.py:194 dcim/forms/bulk_import.py:442 +#: dcim/forms/bulk_import.py:596 dcim/forms/bulk_import.py:1139 +#: dcim/forms/bulk_import.py:1299 dcim/forms/filtersets.py:168 +#: dcim/forms/filtersets.py:227 dcim/forms/filtersets.py:278 +#: dcim/forms/filtersets.py:719 dcim/forms/filtersets.py:828 +#: dcim/forms/filtersets.py:864 dcim/forms/filtersets.py:965 +#: dcim/forms/filtersets.py:1075 dcim/tables/devices.py:173 +#: dcim/tables/devices.py:819 dcim/tables/devices.py:1043 +#: dcim/tables/modules.py:69 dcim/tables/power.py:74 dcim/tables/racks.py:66 +#: dcim/tables/sites.py:82 dcim/tables/sites.py:133 ipam/forms/bulk_edit.py:242 +#: ipam/forms/bulk_edit.py:291 ipam/forms/bulk_edit.py:339 +#: ipam/forms/bulk_edit.py:543 ipam/forms/bulk_import.py:194 +#: ipam/forms/bulk_import.py:259 ipam/forms/bulk_import.py:295 +#: ipam/forms/bulk_import.py:461 ipam/forms/filtersets.py:209 +#: ipam/forms/filtersets.py:274 ipam/forms/filtersets.py:344 +#: ipam/forms/filtersets.py:484 ipam/forms/model_forms.py:451 +#: ipam/tables/ip.py:236 ipam/tables/ip.py:309 ipam/tables/ip.py:359 +#: ipam/tables/ip.py:421 ipam/tables/ip.py:448 ipam/tables/vlans.py:122 +#: ipam/tables/vlans.py:227 templates/circuits/circuit.html:35 +#: templates/core/datasource.html:47 templates/core/job.html:35 +#: templates/dcim/cable.html:20 templates/dcim/device.html:200 +#: templates/dcim/location.html:48 templates/dcim/module.html:67 +#: templates/dcim/powerfeed.html:39 templates/dcim/rack.html:53 +#: templates/dcim/site.html:56 templates/extras/report_list.html:49 +#: templates/extras/script_list.html:55 templates/ipam/ipaddress.html:40 +#: templates/ipam/iprange.html:57 templates/ipam/prefix.html:74 +#: templates/ipam/vlan.html:51 templates/virtualization/cluster.html:24 +#: templates/virtualization/virtualmachine.html:22 +#: templates/wireless/wirelesslan.html:23 +#: templates/wireless/wirelesslink.html:20 users/forms/filtersets.py:35 +#: users/forms/model_forms.py:196 virtualization/forms/bulk_edit.py:67 +#: virtualization/forms/bulk_edit.py:115 virtualization/forms/bulk_import.py:53 +#: virtualization/forms/bulk_import.py:79 virtualization/forms/filtersets.py:58 +#: virtualization/forms/filtersets.py:153 virtualization/tables/clusters.py:74 +#: virtualization/tables/virtualmachines.py:48 wireless/forms/bulk_edit.py:42 +#: wireless/forms/bulk_edit.py:104 wireless/forms/bulk_import.py:43 +#: wireless/forms/bulk_import.py:84 wireless/forms/filtersets.py:48 +#: wireless/forms/filtersets.py:82 wireless/tables/wirelesslan.py:52 +#: wireless/tables/wirelesslink.py:19 +msgid "Status" +msgstr "" + +#: circuits/forms/bulk_edit.py:137 circuits/forms/bulk_import.py:100 +#: circuits/forms/filtersets.py:119 dcim/forms/bulk_edit.py:120 +#: dcim/forms/bulk_edit.py:185 dcim/forms/bulk_edit.py:255 +#: dcim/forms/bulk_edit.py:366 dcim/forms/bulk_edit.py:583 +#: dcim/forms/bulk_edit.py:684 dcim/forms/bulk_edit.py:1590 +#: dcim/forms/bulk_import.py:106 dcim/forms/bulk_import.py:151 +#: dcim/forms/bulk_import.py:187 dcim/forms/bulk_import.py:274 +#: dcim/forms/bulk_import.py:416 dcim/forms/bulk_import.py:1151 +#: dcim/forms/bulk_import.py:1356 dcim/forms/filtersets.py:164 +#: dcim/forms/filtersets.py:195 dcim/forms/filtersets.py:246 +#: dcim/forms/filtersets.py:330 dcim/forms/filtersets.py:351 +#: dcim/forms/filtersets.py:647 dcim/forms/filtersets.py:819 +#: dcim/forms/filtersets.py:884 dcim/forms/filtersets.py:914 +#: dcim/forms/filtersets.py:1035 dcim/tables/power.py:88 +#: extras/filtersets.py:486 extras/forms/filtersets.py:306 +#: extras/forms/filtersets.py:380 ipam/forms/bulk_edit.py:42 +#: ipam/forms/bulk_edit.py:67 ipam/forms/bulk_edit.py:111 +#: ipam/forms/bulk_edit.py:140 ipam/forms/bulk_edit.py:165 +#: ipam/forms/bulk_edit.py:237 ipam/forms/bulk_edit.py:286 +#: ipam/forms/bulk_edit.py:334 ipam/forms/bulk_edit.py:538 +#: ipam/forms/bulk_edit.py:608 ipam/forms/bulk_import.py:40 +#: ipam/forms/bulk_import.py:69 ipam/forms/bulk_import.py:97 +#: ipam/forms/bulk_import.py:117 ipam/forms/bulk_import.py:137 +#: ipam/forms/bulk_import.py:166 ipam/forms/bulk_import.py:252 +#: ipam/forms/bulk_import.py:288 ipam/forms/bulk_import.py:454 +#: ipam/forms/bulk_import.py:518 ipam/forms/filtersets.py:51 +#: ipam/forms/filtersets.py:71 ipam/forms/filtersets.py:103 +#: ipam/forms/filtersets.py:123 ipam/forms/filtersets.py:146 +#: ipam/forms/filtersets.py:173 ipam/forms/filtersets.py:260 +#: ipam/forms/filtersets.py:300 ipam/forms/filtersets.py:453 +#: ipam/forms/filtersets.py:534 ipam/tables/ip.py:451 ipam/tables/vlans.py:224 +#: templates/circuits/circuit.html:39 templates/dcim/cable.html:24 +#: templates/dcim/device.html:98 templates/dcim/location.html:52 +#: templates/dcim/powerfeed.html:47 templates/dcim/rack.html:44 +#: templates/dcim/rackreservation.html:56 templates/dcim/site.html:60 +#: templates/dcim/virtualdevicecontext.html:55 templates/ipam/aggregate.html:31 +#: templates/ipam/asn.html:34 templates/ipam/asnrange.html:30 +#: templates/ipam/ipaddress.html:31 templates/ipam/iprange.html:61 +#: templates/ipam/l2vpn.html:31 templates/ipam/prefix.html:29 +#: templates/ipam/routetarget.html:18 templates/ipam/vlan.html:42 +#: templates/ipam/vrf.html:23 templates/tenancy/tenant.html:17 +#: templates/virtualization/cluster.html:36 +#: templates/virtualization/virtualmachine.html:38 +#: templates/wireless/wirelesslan.html:35 +#: templates/wireless/wirelesslink.html:28 tenancy/forms/forms.py:25 +#: tenancy/forms/forms.py:48 tenancy/forms/model_forms.py:56 +#: tenancy/tables/columns.py:64 virtualization/forms/bulk_edit.py:73 +#: virtualization/forms/bulk_edit.py:152 virtualization/forms/bulk_import.py:65 +#: virtualization/forms/bulk_import.py:114 +#: virtualization/forms/filtersets.py:44 virtualization/forms/filtersets.py:98 +#: wireless/forms/bulk_edit.py:62 wireless/forms/bulk_edit.py:109 +#: wireless/forms/bulk_import.py:55 wireless/forms/bulk_import.py:97 +#: wireless/forms/filtersets.py:34 wireless/forms/filtersets.py:74 +msgid "Tenant" +msgstr "" + +#: circuits/forms/bulk_edit.py:142 circuits/forms/filtersets.py:173 +msgid "Install date" +msgstr "" + +#: circuits/forms/bulk_edit.py:147 circuits/forms/filtersets.py:178 +msgid "Termination date" +msgstr "" + +#: circuits/forms/bulk_edit.py:153 circuits/forms/filtersets.py:185 +msgid "Commit rate (Kbps)" +msgstr "" + +#: circuits/forms/bulk_edit.py:168 circuits/forms/model_forms.py:111 +msgid "Service Parameters" +msgstr "" + +#: circuits/forms/bulk_edit.py:169 circuits/forms/model_forms.py:112 +#: dcim/forms/model_forms.py:141 dcim/forms/model_forms.py:183 +#: dcim/forms/model_forms.py:260 dcim/forms/model_forms.py:671 +#: dcim/forms/model_forms.py:1477 ipam/forms/model_forms.py:63 +#: ipam/forms/model_forms.py:116 ipam/forms/model_forms.py:137 +#: ipam/forms/model_forms.py:161 ipam/forms/model_forms.py:233 +#: ipam/forms/model_forms.py:259 ipam/forms/model_forms.py:781 +#: netbox/navigation/menu.py:38 templates/dcim/cable_edit.html:68 +#: templates/dcim/device_edit.html:85 templates/dcim/rack_edit.html:30 +#: templates/ipam/ipaddress_bulk_add.html:27 +#: templates/ipam/ipaddress_edit.html:27 templates/ipam/vlan_edit.html:22 +#: virtualization/forms/model_forms.py:82 +#: virtualization/forms/model_forms.py:223 wireless/forms/model_forms.py:55 +#: wireless/forms/model_forms.py:160 +msgid "Tenancy" +msgstr "" + +#: circuits/forms/bulk_import.py:38 circuits/forms/bulk_import.py:53 +#: circuits/forms/bulk_import.py:79 +msgid "Assigned provider" +msgstr "" + +#: circuits/forms/bulk_import.py:70 dcim/forms/bulk_import.py:170 +#: dcim/forms/bulk_import.py:380 dcim/forms/bulk_import.py:1092 +#: dcim/forms/bulk_import.py:1171 extras/forms/bulk_import.py:167 +msgid "RGB color in hexadecimal. Example:" +msgstr "" + +#: circuits/forms/bulk_import.py:85 +msgid "Assigned provider account" +msgstr "" + +#: circuits/forms/bulk_import.py:92 +msgid "Type of circuit" +msgstr "" + +#: circuits/forms/bulk_import.py:97 dcim/forms/bulk_import.py:89 +#: dcim/forms/bulk_import.py:148 dcim/forms/bulk_import.py:196 +#: dcim/forms/bulk_import.py:444 dcim/forms/bulk_import.py:598 +#: dcim/forms/bulk_import.py:1301 ipam/forms/bulk_import.py:196 +#: ipam/forms/bulk_import.py:261 ipam/forms/bulk_import.py:297 +#: ipam/forms/bulk_import.py:463 virtualization/forms/bulk_import.py:55 +#: virtualization/forms/bulk_import.py:81 +msgid "Operational status" +msgstr "" + +#: circuits/forms/bulk_import.py:104 dcim/forms/bulk_import.py:110 +#: dcim/forms/bulk_import.py:155 dcim/forms/bulk_import.py:278 +#: dcim/forms/bulk_import.py:420 dcim/forms/bulk_import.py:1155 +#: dcim/forms/bulk_import.py:1296 ipam/forms/bulk_import.py:44 +#: ipam/forms/bulk_import.py:73 ipam/forms/bulk_import.py:101 +#: ipam/forms/bulk_import.py:121 ipam/forms/bulk_import.py:141 +#: ipam/forms/bulk_import.py:170 ipam/forms/bulk_import.py:256 +#: ipam/forms/bulk_import.py:292 ipam/forms/bulk_import.py:458 +#: virtualization/forms/bulk_import.py:69 +#: virtualization/forms/bulk_import.py:118 wireless/forms/bulk_import.py:59 +#: wireless/forms/bulk_import.py:101 +msgid "Assigned tenant" +msgstr "" + +#: circuits/forms/bulk_import.py:123 circuits/forms/filtersets.py:146 +#: circuits/forms/model_forms.py:143 +msgid "Provider network" +msgstr "" + +#: circuits/forms/filtersets.py:26 circuits/forms/filtersets.py:118 +#: dcim/forms/bulk_edit.py:247 dcim/forms/bulk_edit.py:345 +#: dcim/forms/bulk_edit.py:575 dcim/forms/bulk_edit.py:622 +#: dcim/forms/bulk_edit.py:772 dcim/forms/bulk_import.py:181 +#: dcim/forms/bulk_import.py:255 dcim/forms/bulk_import.py:483 +#: dcim/forms/bulk_import.py:1245 dcim/forms/bulk_import.py:1279 +#: dcim/forms/filtersets.py:91 dcim/forms/filtersets.py:243 +#: dcim/forms/filtersets.py:275 dcim/forms/filtersets.py:327 +#: dcim/forms/filtersets.py:378 dcim/forms/filtersets.py:644 +#: dcim/forms/filtersets.py:682 dcim/forms/filtersets.py:883 +#: dcim/forms/filtersets.py:912 dcim/forms/filtersets.py:932 +#: dcim/forms/filtersets.py:996 dcim/forms/filtersets.py:1025 +#: dcim/forms/filtersets.py:1034 dcim/forms/filtersets.py:1145 +#: dcim/forms/filtersets.py:1167 dcim/forms/filtersets.py:1189 +#: dcim/forms/filtersets.py:1206 dcim/forms/filtersets.py:1226 +#: dcim/forms/filtersets.py:1333 dcim/forms/filtersets.py:1355 +#: dcim/forms/filtersets.py:1376 dcim/forms/filtersets.py:1391 +#: dcim/forms/filtersets.py:1402 dcim/forms/model_forms.py:182 +#: dcim/forms/model_forms.py:216 dcim/forms/model_forms.py:402 +#: dcim/forms/model_forms.py:634 dcim/tables/devices.py:190 +#: dcim/tables/power.py:30 dcim/tables/racks.py:58 dcim/tables/racks.py:143 +#: extras/filtersets.py:410 extras/forms/filtersets.py:303 +#: ipam/forms/bulk_edit.py:458 ipam/forms/filtersets.py:172 +#: ipam/forms/filtersets.py:403 ipam/forms/filtersets.py:425 +#: ipam/forms/filtersets.py:451 ipam/forms/model_forms.py:562 +#: templates/dcim/device.html:34 templates/dcim/device_edit.html:30 +#: templates/dcim/inc/cable_termination.html:12 templates/dcim/location.html:27 +#: templates/dcim/powerpanel.html:27 templates/dcim/rack.html:27 +#: templates/dcim/rackreservation.html:34 virtualization/forms/filtersets.py:43 +#: virtualization/forms/filtersets.py:96 wireless/forms/model_forms.py:88 +#: wireless/forms/model_forms.py:128 +msgid "Location" +msgstr "" + +#: circuits/forms/filtersets.py:27 ipam/forms/model_forms.py:160 +#: ipam/models/asns.py:108 ipam/models/asns.py:125 ipam/tables/asn.py:41 +#: templates/ipam/asn.html:20 +msgid "ASN" +msgstr "" + +#: circuits/forms/filtersets.py:28 circuits/forms/filtersets.py:120 +#: dcim/forms/filtersets.py:135 dcim/forms/filtersets.py:149 +#: dcim/forms/filtersets.py:165 dcim/forms/filtersets.py:196 +#: dcim/forms/filtersets.py:247 dcim/forms/filtersets.py:331 +#: dcim/forms/filtersets.py:405 dcim/forms/filtersets.py:648 +#: dcim/forms/filtersets.py:997 netbox/navigation/menu.py:45 +#: netbox/navigation/menu.py:47 tenancy/tables/columns.py:70 +#: tenancy/tables/contacts.py:25 tenancy/views.py:23 +#: virtualization/forms/filtersets.py:34 virtualization/forms/filtersets.py:45 +#: virtualization/forms/filtersets.py:99 +msgid "Contacts" +msgstr "" + +#: circuits/forms/filtersets.py:33 circuits/forms/filtersets.py:156 +#: dcim/forms/bulk_edit.py:110 dcim/forms/bulk_edit.py:222 +#: dcim/forms/bulk_edit.py:747 dcim/forms/bulk_import.py:92 +#: dcim/forms/filtersets.py:69 dcim/forms/filtersets.py:175 +#: dcim/forms/filtersets.py:201 dcim/forms/filtersets.py:253 +#: dcim/forms/filtersets.py:356 dcim/forms/filtersets.py:659 +#: dcim/forms/filtersets.py:889 dcim/forms/filtersets.py:919 +#: dcim/forms/filtersets.py:1002 dcim/forms/filtersets.py:1041 +#: dcim/forms/filtersets.py:1451 dcim/forms/filtersets.py:1475 +#: dcim/forms/filtersets.py:1499 dcim/forms/model_forms.py:80 +#: dcim/forms/model_forms.py:115 dcim/forms/object_create.py:341 +#: dcim/tables/devices.py:176 dcim/tables/sites.py:85 extras/filtersets.py:377 +#: ipam/forms/bulk_edit.py:207 ipam/forms/bulk_edit.py:439 +#: ipam/forms/bulk_edit.py:511 ipam/forms/filtersets.py:216 +#: ipam/forms/filtersets.py:410 ipam/forms/filtersets.py:458 +#: ipam/forms/filtersets.py:576 ipam/forms/model_forms.py:534 +#: templates/dcim/device.html:17 templates/dcim/region.html:26 +#: templates/dcim/site.html:30 virtualization/forms/bulk_edit.py:78 +#: virtualization/forms/filtersets.py:55 virtualization/forms/filtersets.py:126 +#: virtualization/forms/model_forms.py:94 +msgid "Region" +msgstr "" + +#: circuits/forms/filtersets.py:38 circuits/forms/filtersets.py:161 +#: dcim/forms/bulk_edit.py:230 dcim/forms/bulk_edit.py:755 +#: dcim/forms/filtersets.py:74 dcim/forms/filtersets.py:180 +#: dcim/forms/filtersets.py:206 dcim/forms/filtersets.py:266 +#: dcim/forms/filtersets.py:361 dcim/forms/filtersets.py:664 +#: dcim/forms/filtersets.py:894 dcim/forms/filtersets.py:1007 +#: dcim/forms/filtersets.py:1046 dcim/forms/object_create.py:349 +#: extras/filtersets.py:394 ipam/forms/bulk_edit.py:212 +#: ipam/forms/bulk_edit.py:446 ipam/forms/bulk_edit.py:516 +#: ipam/forms/filtersets.py:221 ipam/forms/filtersets.py:415 +#: ipam/forms/filtersets.py:463 ipam/forms/model_forms.py:547 +#: virtualization/forms/bulk_edit.py:83 virtualization/forms/filtersets.py:65 +#: virtualization/forms/filtersets.py:131 +#: virtualization/forms/model_forms.py:100 +msgid "Site group" +msgstr "" + +#: circuits/forms/filtersets.py:51 +msgid "ASN (legacy)" +msgstr "" + +#: circuits/forms/filtersets.py:65 circuits/forms/filtersets.py:83 +#: circuits/forms/filtersets.py:102 circuits/forms/filtersets.py:117 +#: core/forms/filtersets.py:64 dcim/forms/bulk_edit.py:718 +#: dcim/forms/filtersets.py:163 dcim/forms/filtersets.py:194 +#: dcim/forms/filtersets.py:818 dcim/forms/filtersets.py:913 +#: dcim/forms/filtersets.py:1036 dcim/forms/filtersets.py:1144 +#: dcim/forms/filtersets.py:1166 dcim/forms/filtersets.py:1188 +#: dcim/forms/filtersets.py:1205 dcim/forms/filtersets.py:1222 +#: dcim/forms/filtersets.py:1332 dcim/forms/filtersets.py:1354 +#: dcim/forms/filtersets.py:1375 dcim/forms/filtersets.py:1390 +#: dcim/forms/filtersets.py:1401 extras/forms/filtersets.py:42 +#: extras/forms/filtersets.py:108 extras/forms/filtersets.py:139 +#: extras/forms/filtersets.py:179 extras/forms/filtersets.py:195 +#: extras/forms/filtersets.py:228 extras/forms/filtersets.py:425 +#: extras/forms/filtersets.py:466 ipam/forms/filtersets.py:102 +#: ipam/forms/filtersets.py:259 ipam/forms/filtersets.py:298 +#: ipam/forms/filtersets.py:371 ipam/forms/filtersets.py:452 +#: ipam/forms/filtersets.py:510 ipam/forms/filtersets.py:533 +#: virtualization/forms/filtersets.py:42 virtualization/forms/filtersets.py:97 +#: virtualization/forms/filtersets.py:187 wireless/forms/filtersets.py:33 +#: wireless/forms/filtersets.py:73 +msgid "Attributes" +msgstr "" + +#: circuits/forms/filtersets.py:73 circuits/tables/circuits.py:60 +#: circuits/tables/providers.py:66 templates/circuits/circuit.html:23 +#: templates/circuits/provideraccount.html:25 +msgid "Account" +msgstr "" + +#: circuits/forms/model_forms.py:64 +#: templates/circuits/circuittermination_edit.html:23 +#: templates/circuits/inc/circuit_termination.html:89 +#: templates/circuits/providernetwork.html:18 +msgid "Provider Network" +msgstr "" + +#: circuits/forms/model_forms.py:78 templates/circuits/circuittype.html:20 +msgid "Circuit Type" +msgstr "" + +#: circuits/models/circuits.py:25 dcim/models/cables.py:68 +#: dcim/models/device_component_templates.py:492 +#: dcim/models/device_component_templates.py:592 +#: dcim/models/device_components.py:967 dcim/models/device_components.py:1041 +#: dcim/models/device_components.py:1157 dcim/models/devices.py:467 +#: dcim/models/racks.py:43 extras/models/tags.py:31 +msgid "color" +msgstr "" + +#: circuits/models/circuits.py:34 +msgid "circuit type" +msgstr "" + +#: circuits/models/circuits.py:35 +msgid "circuit types" +msgstr "" + +#: circuits/models/circuits.py:46 +msgid "circuit ID" +msgstr "" + +#: circuits/models/circuits.py:47 +msgid "Unique circuit ID" +msgstr "" + +#: circuits/models/circuits.py:67 core/models/data.py:55 core/models/jobs.py:85 +#: dcim/models/cables.py:50 dcim/models/devices.py:641 +#: dcim/models/devices.py:1160 dcim/models/devices.py:1369 +#: dcim/models/power.py:95 dcim/models/racks.py:97 dcim/models/sites.py:154 +#: dcim/models/sites.py:266 ipam/models/ip.py:252 ipam/models/ip.py:521 +#: ipam/models/ip.py:729 ipam/models/vlans.py:173 +#: virtualization/models/clusters.py:74 +#: virtualization/models/virtualmachines.py:81 wireless/models.py:94 +#: wireless/models.py:158 +msgid "status" +msgstr "" + +#: circuits/models/circuits.py:82 +msgid "installed" +msgstr "" + +#: circuits/models/circuits.py:87 +msgid "terminates" +msgstr "" + +#: circuits/models/circuits.py:92 +msgid "commit rate (Kbps)" +msgstr "" + +#: circuits/models/circuits.py:93 +msgid "Committed rate" +msgstr "" + +#: circuits/models/circuits.py:135 +msgid "circuit" +msgstr "" + +#: circuits/models/circuits.py:136 +msgid "circuits" +msgstr "" + +#: circuits/models/circuits.py:169 +msgid "termination" +msgstr "" + +#: circuits/models/circuits.py:186 +msgid "port speed (Kbps)" +msgstr "" + +#: circuits/models/circuits.py:189 +msgid "Physical circuit speed" +msgstr "" + +#: circuits/models/circuits.py:194 +msgid "upstream speed (Kbps)" +msgstr "" + +#: circuits/models/circuits.py:195 +msgid "Upstream speed, if different from port speed" +msgstr "" + +#: circuits/models/circuits.py:200 +msgid "cross-connect ID" +msgstr "" + +#: circuits/models/circuits.py:201 +msgid "ID of the local cross-connect" +msgstr "" + +#: circuits/models/circuits.py:206 +msgid "patch panel/port(s)" +msgstr "" + +#: circuits/models/circuits.py:207 +msgid "Patch panel ID and port number(s)" +msgstr "" + +#: circuits/models/circuits.py:210 dcim/models/device_component_templates.py:62 +#: dcim/models/device_components.py:70 dcim/models/racks.py:536 +#: extras/models/configs.py:45 extras/models/configs.py:219 +#: extras/models/customfields.py:116 extras/models/models.py:343 +#: extras/models/models.py:458 extras/models/staging.py:31 +#: extras/models/tags.py:35 netbox/models/__init__.py:109 +#: netbox/models/__init__.py:144 netbox/models/__init__.py:190 +#: users/models.py:270 users/models.py:345 +#: virtualization/models/virtualmachines.py:256 +msgid "description" +msgstr "" + +#: circuits/models/circuits.py:223 +msgid "circuit termination" +msgstr "" + +#: circuits/models/circuits.py:224 +msgid "circuit terminations" +msgstr "" + +#: circuits/models/providers.py:22 circuits/models/providers.py:66 +#: circuits/models/providers.py:104 core/models/data.py:42 +#: core/models/jobs.py:46 dcim/models/device_component_templates.py:44 +#: dcim/models/device_components.py:55 dcim/models/devices.py:581 +#: dcim/models/devices.py:1300 dcim/models/devices.py:1365 +#: dcim/models/power.py:39 dcim/models/power.py:91 dcim/models/racks.py:62 +#: dcim/models/sites.py:138 extras/models/configs.py:36 +#: extras/models/configs.py:215 extras/models/customfields.py:83 +#: extras/models/models.py:55 extras/models/models.py:243 +#: extras/models/models.py:339 extras/models/models.py:448 +#: extras/models/models.py:543 extras/models/staging.py:26 +#: ipam/models/asns.py:18 ipam/models/fhrp.py:26 ipam/models/l2vpn.py:22 +#: ipam/models/services.py:52 ipam/models/services.py:88 +#: ipam/models/vlans.py:27 ipam/models/vlans.py:162 ipam/models/vrfs.py:22 +#: ipam/models/vrfs.py:79 netbox/models/__init__.py:136 +#: netbox/models/__init__.py:180 tenancy/models/contacts.py:63 +#: tenancy/models/tenants.py:20 tenancy/models/tenants.py:45 +#: users/models.py:341 virtualization/models/clusters.py:57 +#: virtualization/models/virtualmachines.py:69 +#: virtualization/models/virtualmachines.py:246 wireless/models.py:50 +msgid "name" +msgstr "" + +#: circuits/models/providers.py:25 +msgid "Full name of the provider" +msgstr "" + +#: circuits/models/providers.py:28 dcim/models/devices.py:86 +#: dcim/models/sites.py:149 extras/models/models.py:453 ipam/models/asns.py:23 +#: ipam/models/l2vpn.py:27 ipam/models/vlans.py:31 +#: netbox/models/__init__.py:140 netbox/models/__init__.py:185 +#: tenancy/models/tenants.py:25 tenancy/models/tenants.py:49 +#: wireless/models.py:55 +msgid "slug" +msgstr "" + +#: circuits/models/providers.py:42 +msgid "provider" +msgstr "" + +#: circuits/models/providers.py:43 +msgid "providers" +msgstr "" + +#: circuits/models/providers.py:63 +msgid "account ID" +msgstr "" + +#: circuits/models/providers.py:86 +msgid "provider account" +msgstr "" + +#: circuits/models/providers.py:87 +msgid "provider accounts" +msgstr "" + +#: circuits/models/providers.py:115 +msgid "service ID" +msgstr "" + +#: circuits/models/providers.py:126 +msgid "provider network" +msgstr "" + +#: circuits/models/providers.py:127 +msgid "provider networks" +msgstr "" + +#: circuits/tables/circuits.py:29 circuits/tables/providers.py:18 +#: circuits/tables/providers.py:69 circuits/tables/providers.py:99 +#: core/tables/data.py:16 core/tables/jobs.py:14 dcim/forms/filtersets.py:59 +#: dcim/forms/object_create.py:42 dcim/tables/devices.py:88 +#: dcim/tables/devices.py:125 dcim/tables/devices.py:167 +#: dcim/tables/devices.py:318 dcim/tables/devices.py:395 +#: dcim/tables/devices.py:439 dcim/tables/devices.py:485 +#: dcim/tables/devices.py:537 dcim/tables/devices.py:646 +#: dcim/tables/devices.py:727 dcim/tables/devices.py:777 +#: dcim/tables/devices.py:843 dcim/tables/devices.py:954 +#: dcim/tables/devices.py:974 dcim/tables/devices.py:1003 +#: dcim/tables/devices.py:1033 dcim/tables/devicetypes.py:32 +#: dcim/tables/power.py:22 dcim/tables/power.py:62 dcim/tables/racks.py:23 +#: dcim/tables/racks.py:53 dcim/tables/sites.py:24 dcim/tables/sites.py:51 +#: dcim/tables/sites.py:78 dcim/tables/sites.py:125 +#: extras/forms/filtersets.py:187 extras/tables/tables.py:65 +#: extras/tables/tables.py:105 extras/tables/tables.py:137 +#: extras/tables/tables.py:161 extras/tables/tables.py:226 +#: extras/tables/tables.py:273 extras/tables/tables.py:319 +#: extras/tables/tables.py:371 extras/tables/tables.py:394 +#: ipam/forms/bulk_edit.py:392 ipam/forms/filtersets.py:375 +#: ipam/tables/asn.py:16 ipam/tables/ip.py:85 ipam/tables/ip.py:159 +#: ipam/tables/l2vpn.py:23 ipam/tables/services.py:15 +#: ipam/tables/services.py:40 ipam/tables/vlans.py:64 ipam/tables/vlans.py:110 +#: ipam/tables/vrfs.py:26 ipam/tables/vrfs.py:67 +#: templates/circuits/circuittype.html:25 +#: templates/circuits/provideraccount.html:29 +#: templates/circuits/providernetwork.html:27 templates/core/datasource.html:35 +#: templates/core/job.html:31 templates/dcim/consoleport.html:31 +#: templates/dcim/consoleserverport.html:31 templates/dcim/devicebay.html:27 +#: templates/dcim/devicerole.html:29 templates/dcim/frontport.html:31 +#: templates/dcim/inc/interface_vlans_table.html:5 +#: templates/dcim/inc/panels/inventory_items.html:10 +#: templates/dcim/interface.html:39 templates/dcim/interface.html:167 +#: templates/dcim/inventoryitem.html:29 +#: templates/dcim/inventoryitemrole.html:19 templates/dcim/location.html:32 +#: templates/dcim/manufacturer.html:39 templates/dcim/modulebay.html:27 +#: templates/dcim/platform.html:32 templates/dcim/poweroutlet.html:31 +#: templates/dcim/powerport.html:31 templates/dcim/rackrole.html:25 +#: templates/dcim/rearport.html:31 templates/dcim/region.html:30 +#: templates/dcim/sitegroup.html:30 templates/dcim/virtualdevicecontext.html:21 +#: templates/extras/admin/plugins_list.html:22 +#: templates/extras/configcontext.html:14 +#: templates/extras/configtemplate.html:14 templates/extras/customfield.html:16 +#: templates/extras/customlink.html:14 templates/extras/exporttemplate.html:21 +#: templates/extras/report_list.html:46 templates/extras/savedfilter.html:14 +#: templates/extras/script_list.html:52 templates/extras/tag.html:17 +#: templates/extras/webhook.html:16 templates/ipam/asnrange.html:16 +#: templates/ipam/fhrpgroup.html:31 templates/ipam/l2vpn.html:15 +#: templates/ipam/rir.html:25 templates/ipam/role.html:25 +#: templates/ipam/routetarget.html:14 templates/ipam/service.html:27 +#: templates/ipam/servicetemplate.html:16 templates/ipam/vlan.html:38 +#: templates/ipam/vlangroup.html:31 templates/tenancy/contact.html:26 +#: templates/tenancy/contactgroup.html:24 templates/tenancy/contactrole.html:19 +#: templates/tenancy/tenantgroup.html:32 templates/users/group.html:18 +#: templates/users/objectpermission.html:18 +#: templates/virtualization/cluster.html:16 +#: templates/virtualization/clustergroup.html:25 +#: templates/virtualization/clustertype.html:25 +#: templates/virtualization/virtualmachine.html:18 +#: templates/virtualization/vminterface.html:28 +#: templates/wireless/wirelesslangroup.html:30 tenancy/tables/contacts.py:19 +#: tenancy/tables/contacts.py:41 tenancy/tables/contacts.py:56 +#: tenancy/tables/tenants.py:16 tenancy/tables/tenants.py:38 users/tables.py:62 +#: users/tables.py:79 virtualization/forms/bulk_create.py:19 +#: virtualization/forms/object_create.py:12 +#: virtualization/tables/clusters.py:17 virtualization/tables/clusters.py:39 +#: virtualization/tables/clusters.py:62 +#: virtualization/tables/virtualmachines.py:43 +#: virtualization/tables/virtualmachines.py:114 +#: wireless/tables/wirelesslan.py:18 wireless/tables/wirelesslan.py:79 +msgid "Name" +msgstr "" + +#: circuits/tables/circuits.py:38 circuits/tables/providers.py:45 +#: circuits/tables/providers.py:79 netbox/navigation/menu.py:235 +#: netbox/navigation/menu.py:239 netbox/navigation/menu.py:241 +#: templates/circuits/provider.html:61 +#: templates/circuits/provideraccount.html:46 +#: templates/circuits/providernetwork.html:54 +msgid "Circuits" +msgstr "" + +#: circuits/tables/circuits.py:52 templates/circuits/circuit.html:27 +msgid "Circuit ID" +msgstr "" + +#: circuits/tables/circuits.py:65 wireless/forms/model_forms.py:157 +msgid "Side A" +msgstr "" + +#: circuits/tables/circuits.py:69 +msgid "Side Z" +msgstr "" + +#: circuits/tables/circuits.py:72 templates/circuits/circuit.html:56 +msgid "Commit Rate" +msgstr "" + +#: circuits/tables/circuits.py:75 circuits/tables/providers.py:48 +#: circuits/tables/providers.py:82 circuits/tables/providers.py:107 +#: dcim/tables/devices.py:1016 dcim/tables/devicetypes.py:92 +#: dcim/tables/modules.py:29 dcim/tables/modules.py:72 dcim/tables/power.py:39 +#: dcim/tables/power.py:91 dcim/tables/racks.py:76 dcim/tables/racks.py:156 +#: dcim/tables/sites.py:103 extras/forms/bulk_edit.py:299 +#: extras/tables/tables.py:485 ipam/tables/asn.py:68 ipam/tables/fhrp.py:34 +#: ipam/tables/ip.py:135 ipam/tables/ip.py:272 ipam/tables/ip.py:325 +#: ipam/tables/ip.py:392 ipam/tables/l2vpn.py:37 ipam/tables/services.py:24 +#: ipam/tables/services.py:54 ipam/tables/vlans.py:141 ipam/tables/vrfs.py:46 +#: ipam/tables/vrfs.py:71 templates/dcim/cable_edit.html:85 +#: templates/generic/bulk_edit.html:102 templates/inc/panels/comments.html:6 +#: tenancy/tables/contacts.py:68 tenancy/tables/tenants.py:46 +#: utilities/forms/fields/fields.py:29 virtualization/tables/clusters.py:91 +#: virtualization/tables/virtualmachines.py:66 +#: wireless/tables/wirelesslan.py:27 wireless/tables/wirelesslan.py:58 +msgid "Comments" +msgstr "" + +#: circuits/tables/providers.py:23 +msgid "Accounts" +msgstr "" + +#: circuits/tables/providers.py:29 +msgid "Account Count" +msgstr "" + +#: circuits/tables/providers.py:39 dcim/tables/sites.py:100 +msgid "ASN Count" +msgstr "" + +#: core/choices.py:18 +msgid "New" +msgstr "" + +#: core/choices.py:19 +msgid "Queued" +msgstr "" + +#: core/choices.py:20 +msgid "Syncing" +msgstr "" + +#: core/choices.py:21 core/choices.py:57 core/tables/jobs.py:40 +#: extras/choices.py:199 templates/core/job.html:69 +msgid "Completed" +msgstr "" + +#: core/choices.py:22 core/choices.py:59 dcim/choices.py:176 +#: dcim/choices.py:222 dcim/choices.py:1496 extras/choices.py:201 +#: virtualization/choices.py:47 +msgid "Failed" +msgstr "" + +#: core/choices.py:35 netbox/navigation/menu.py:311 +#: templates/extras/script/base.html:14 templates/extras/script_list.html:6 +#: templates/extras/script_list.html:20 templates/extras/script_result.html:18 +msgid "Scripts" +msgstr "" + +#: core/choices.py:36 netbox/navigation/menu.py:305 +#: templates/extras/report/base.html:13 templates/extras/report_list.html:7 +#: templates/extras/report_list.html:12 +msgid "Reports" +msgstr "" + +#: core/choices.py:54 extras/choices.py:196 +msgid "Pending" +msgstr "" + +#: core/choices.py:55 core/tables/jobs.py:31 extras/choices.py:197 +#: templates/core/job.html:56 +msgid "Scheduled" +msgstr "" + +#: core/choices.py:56 extras/choices.py:198 +msgid "Running" +msgstr "" + +#: core/choices.py:58 extras/choices.py:200 +msgid "Errored" +msgstr "" + +#: core/data_backends.py:29 templates/dcim/interface.html:220 +msgid "Local" +msgstr "" + +#: core/data_backends.py:47 extras/tables/tables.py:431 +#: templates/account/profile.html:16 templates/users/user.html:18 +#: users/tables.py:31 +msgid "Username" +msgstr "" + +#: core/data_backends.py:49 core/data_backends.py:55 +msgid "Only used for cloning with HTTP(S)" +msgstr "" + +#: core/data_backends.py:53 templates/account/base.html:17 +#: templates/account/password.html:11 users/forms/model_forms.py:171 +msgid "Password" +msgstr "" + +#: core/data_backends.py:59 +msgid "Branch" +msgstr "" + +#: core/data_backends.py:118 +msgid "AWS access key ID" +msgstr "" + +#: core/data_backends.py:122 +msgid "AWS secret access key" +msgstr "" + +#: core/filtersets.py:48 extras/filtersets.py:172 extras/filtersets.py:507 +#: extras/filtersets.py:535 +msgid "Data source (ID)" +msgstr "" + +#: core/filtersets.py:54 +msgid "Data source (name)" +msgstr "" + +#: core/forms/bulk_edit.py:24 ipam/forms/bulk_edit.py:49 +msgid "Enforce unique space" +msgstr "" + +#: core/forms/bulk_edit.py:33 extras/forms/model_forms.py:196 +#: templates/extras/savedfilter.html:57 +msgid "Parameters" +msgstr "" + +#: core/forms/bulk_edit.py:37 templates/core/datasource.html:69 +msgid "Ignore rules" +msgstr "" + +#: core/forms/filtersets.py:27 core/forms/model_forms.py:89 +#: extras/forms/model_forms.py:159 extras/forms/model_forms.py:352 +#: extras/forms/model_forms.py:405 extras/tables/tables.py:171 +#: extras/tables/tables.py:363 extras/tables/tables.py:398 +#: templates/core/datasource.html:31 +#: templates/dcim/device/render_config.html:19 +#: templates/extras/configcontext.html:30 +#: templates/extras/configtemplate.html:22 +#: templates/extras/exporttemplate.html:41 +#: templates/virtualization/virtualmachine/render_config.html:19 +msgid "Data Source" +msgstr "" + +#: core/forms/filtersets.py:40 core/tables/data.py:26 +#: dcim/forms/bulk_edit.py:1012 dcim/forms/bulk_edit.py:1285 +#: dcim/forms/filtersets.py:1261 dcim/tables/devices.py:562 +#: dcim/tables/devicetypes.py:221 extras/forms/bulk_edit.py:92 +#: extras/forms/bulk_edit.py:156 extras/forms/bulk_edit.py:177 +#: extras/forms/filtersets.py:116 extras/forms/filtersets.py:203 +#: extras/forms/filtersets.py:242 extras/tables/tables.py:144 +#: extras/tables/tables.py:233 extras/tables/tables.py:280 +#: templates/core/datasource.html:43 templates/dcim/interface.html:62 +#: templates/extras/customlink.html:18 templates/extras/savedfilter.html:26 +#: templates/extras/webhook.html:20 templates/users/objectpermission.html:26 +#: templates/virtualization/vminterface.html:32 users/forms/bulk_edit.py:69 +#: users/forms/filtersets.py:73 users/tables.py:86 +#: virtualization/forms/bulk_edit.py:214 virtualization/forms/filtersets.py:203 +msgid "Enabled" +msgstr "" + +#: core/forms/filtersets.py:52 core/forms/mixins.py:21 +msgid "File" +msgstr "" + +#: core/forms/filtersets.py:57 core/forms/mixins.py:16 +#: extras/forms/filtersets.py:144 extras/forms/filtersets.py:311 +#: extras/forms/filtersets.py:397 +msgid "Data source" +msgstr "" + +#: core/forms/filtersets.py:65 extras/forms/filtersets.py:424 +msgid "Creation" +msgstr "" + +#: core/forms/filtersets.py:71 extras/forms/filtersets.py:448 +#: extras/forms/filtersets.py:494 extras/tables/tables.py:474 +#: ipam/tables/l2vpn.py:59 templates/core/job.html:25 +#: templates/extras/objectchange.html:56 tenancy/tables/contacts.py:90 +msgid "Object Type" +msgstr "" + +#: core/forms/filtersets.py:81 +msgid "Created after" +msgstr "" + +#: core/forms/filtersets.py:86 +msgid "Created before" +msgstr "" + +#: core/forms/filtersets.py:91 +msgid "Scheduled after" +msgstr "" + +#: core/forms/filtersets.py:96 +msgid "Scheduled before" +msgstr "" + +#: core/forms/filtersets.py:101 +msgid "Started after" +msgstr "" + +#: core/forms/filtersets.py:106 +msgid "Started before" +msgstr "" + +#: core/forms/filtersets.py:111 +msgid "Completed after" +msgstr "" + +#: core/forms/filtersets.py:116 +msgid "Completed before" +msgstr "" + +#: core/forms/filtersets.py:123 dcim/forms/bulk_edit.py:359 +#: dcim/forms/filtersets.py:349 dcim/forms/filtersets.py:393 +#: dcim/forms/model_forms.py:251 extras/forms/filtersets.py:440 +#: extras/forms/filtersets.py:486 templates/dcim/rackreservation.html:65 +#: templates/extras/objectchange.html:40 templates/extras/savedfilter.html:22 +#: templates/users/token.html:22 templates/users/user.html:6 +#: templates/users/user.html:14 users/filtersets.py:74 users/filtersets.py:134 +#: users/forms/filtersets.py:87 users/forms/filtersets.py:128 +#: users/forms/model_forms.py:156 users/forms/model_forms.py:194 +#: users/tables.py:19 +msgid "User" +msgstr "" + +#: core/forms/model_forms.py:46 core/tables/data.py:46 +#: templates/core/datafile.html:36 templates/extras/report/base.html:33 +#: templates/extras/script/base.html:32 templates/extras/script_result.html:45 +msgid "Source" +msgstr "" + +#: core/forms/model_forms.py:50 +msgid "Backend Parameters" +msgstr "" + +#: core/forms/model_forms.py:88 +msgid "File Upload" +msgstr "" + +#: core/models/data.py:47 dcim/models/cables.py:44 +#: dcim/models/device_component_templates.py:178 +#: dcim/models/device_component_templates.py:212 +#: dcim/models/device_component_templates.py:247 +#: dcim/models/device_component_templates.py:309 +#: dcim/models/device_component_templates.py:388 +#: dcim/models/device_component_templates.py:487 +#: dcim/models/device_component_templates.py:587 +#: dcim/models/device_components.py:285 dcim/models/device_components.py:314 +#: dcim/models/device_components.py:347 dcim/models/device_components.py:465 +#: dcim/models/device_components.py:603 dcim/models/device_components.py:962 +#: dcim/models/device_components.py:1036 dcim/models/power.py:101 +#: dcim/models/racks.py:127 extras/models/customfields.py:69 +#: extras/models/search.py:41 ipam/models/l2vpn.py:32 +#: virtualization/models/clusters.py:61 +msgid "type" +msgstr "" + +#: core/models/data.py:52 extras/choices.py:34 extras/models/models.py:86 +#: templates/core/datasource.html:59 +msgid "URL" +msgstr "" + +#: core/models/data.py:62 dcim/models/device_component_templates.py:393 +#: dcim/models/device_components.py:514 extras/models/models.py:93 +#: extras/models/models.py:248 extras/models/models.py:473 users/models.py:350 +msgid "enabled" +msgstr "" + +#: core/models/data.py:66 +msgid "ignore rules" +msgstr "" + +#: core/models/data.py:68 +msgid "Patterns (one per line) matching files to ignore when syncing" +msgstr "" + +#: core/models/data.py:71 extras/models/models.py:481 +msgid "parameters" +msgstr "" + +#: core/models/data.py:76 +msgid "last synced" +msgstr "" + +#: core/models/data.py:84 +msgid "data source" +msgstr "" + +#: core/models/data.py:85 +msgid "data sources" +msgstr "" + +#: core/models/data.py:124 +#, python-brace-format +msgid "Unknown backend type: {type}" +msgstr "" + +#: core/models/data.py:259 core/models/files.py:26 core/models/jobs.py:50 +#: extras/models/models.py:663 extras/models/models.py:704 +#: netbox/models/features.py:51 users/models.py:245 +msgid "created" +msgstr "" + +#: core/models/data.py:263 core/models/files.py:30 netbox/models/features.py:57 +msgid "last updated" +msgstr "" + +#: core/models/data.py:273 dcim/models/cables.py:417 +msgid "path" +msgstr "" + +#: core/models/data.py:276 +msgid "File path relative to the data source's root" +msgstr "" + +#: core/models/data.py:280 ipam/models/ip.py:502 +msgid "size" +msgstr "" + +#: core/models/data.py:283 +msgid "hash" +msgstr "" + +#: core/models/data.py:287 +msgid "Length must be 64 hexadecimal characters." +msgstr "" + +#: core/models/data.py:289 +msgid "SHA256 hash of the file data" +msgstr "" + +#: core/models/data.py:306 +msgid "data file" +msgstr "" + +#: core/models/data.py:307 +msgid "data files" +msgstr "" + +#: core/models/data.py:391 +msgid "auto sync record" +msgstr "" + +#: core/models/data.py:392 +msgid "auto sync records" +msgstr "" + +#: core/models/files.py:36 +msgid "file root" +msgstr "" + +#: core/models/files.py:41 +msgid "file path" +msgstr "" + +#: core/models/files.py:43 +msgid "File path relative to the designated root path" +msgstr "" + +#: core/models/files.py:59 +msgid "managed file" +msgstr "" + +#: core/models/files.py:60 +msgid "managed files" +msgstr "" + +#: core/models/jobs.py:54 +msgid "scheduled" +msgstr "" + +#: core/models/jobs.py:59 +msgid "interval" +msgstr "" + +#: core/models/jobs.py:65 +msgid "Recurrence interval (in minutes)" +msgstr "" + +#: core/models/jobs.py:68 +msgid "started" +msgstr "" + +#: core/models/jobs.py:73 +msgid "completed" +msgstr "" + +#: core/models/jobs.py:91 extras/models/staging.py:87 +msgid "data" +msgstr "" + +#: core/models/jobs.py:96 +msgid "job ID" +msgstr "" + +#: core/models/jobs.py:104 +msgid "job" +msgstr "" + +#: core/models/jobs.py:105 +msgid "jobs" +msgstr "" + +#: core/tables/data.py:50 templates/core/datafile.html:40 +msgid "Path" +msgstr "" + +#: core/tables/data.py:54 templates/extras/inc/result_pending.html:7 +msgid "Last updated" +msgstr "" + +#: core/tables/jobs.py:10 dcim/tables/devicetypes.py:161 +#: extras/tables/tables.py:196 extras/tables/tables.py:340 +#: netbox/tables/tables.py:180 templates/dcim/virtualchassis_edit.html:53 +#: wireless/tables/wirelesslink.py:16 +msgid "ID" +msgstr "" + +#: core/tables/jobs.py:21 extras/choices.py:38 extras/tables/tables.py:258 +#: extras/tables/tables.py:350 extras/tables/tables.py:448 +#: extras/tables/tables.py:479 ipam/tables/l2vpn.py:64 +#: netbox/tables/tables.py:229 templates/extras/htmx/report_result.html:45 +#: templates/extras/journalentry.html:21 templates/extras/objectchange.html:62 +#: tenancy/tables/contacts.py:93 +msgid "Object" +msgstr "" + +#: core/tables/jobs.py:34 +msgid "Interval" +msgstr "" + +#: core/tables/jobs.py:37 templates/core/job.html:65 +#: templates/extras/htmx/report_result.html:7 +#: templates/extras/htmx/script_result.html:8 +msgid "Started" +msgstr "" + +#: dcim/api/serializers.py:205 templates/dcim/rack.html:40 +msgid "Facility ID" +msgstr "" + +#: dcim/api/serializers.py:321 dcim/api/serializers.py:680 +msgid "Position (U)" +msgstr "" + +#: dcim/choices.py:21 virtualization/choices.py:21 +msgid "Staging" +msgstr "" + +#: dcim/choices.py:23 dcim/choices.py:178 dcim/choices.py:223 +#: dcim/choices.py:1420 virtualization/choices.py:23 +#: virtualization/choices.py:48 +msgid "Decommissioning" +msgstr "" + +#: dcim/choices.py:24 +msgid "Retired" +msgstr "" + +#: dcim/choices.py:65 +msgid "2-post frame" +msgstr "" + +#: dcim/choices.py:66 +msgid "4-post frame" +msgstr "" + +#: dcim/choices.py:67 +msgid "4-post cabinet" +msgstr "" + +#: dcim/choices.py:68 +msgid "Wall-mounted frame" +msgstr "" + +#: dcim/choices.py:69 +msgid "Wall-mounted frame (vertical)" +msgstr "" + +#: dcim/choices.py:70 +msgid "Wall-mounted cabinet" +msgstr "" + +#: dcim/choices.py:71 +msgid "Wall-mounted cabinet (vertical)" +msgstr "" + +#: dcim/choices.py:83 dcim/choices.py:84 dcim/choices.py:85 dcim/choices.py:86 +#, python-brace-format +msgid "{n} inches" +msgstr "" + +#: dcim/choices.py:100 ipam/choices.py:32 ipam/choices.py:50 ipam/choices.py:70 +#: ipam/choices.py:155 wireless/choices.py:26 +msgid "Reserved" +msgstr "" + +#: dcim/choices.py:101 templates/dcim/device.html:279 +msgid "Available" +msgstr "" + +#: dcim/choices.py:104 ipam/choices.py:33 ipam/choices.py:51 ipam/choices.py:71 +#: ipam/choices.py:156 wireless/choices.py:28 +msgid "Deprecated" +msgstr "" + +#: dcim/choices.py:114 templates/dcim/rack.html:135 +msgid "Millimeters" +msgstr "" + +#: dcim/choices.py:115 dcim/choices.py:1442 +msgid "Inches" +msgstr "" + +#: dcim/choices.py:140 dcim/forms/bulk_edit.py:66 dcim/forms/bulk_edit.py:85 +#: dcim/forms/bulk_edit.py:171 dcim/forms/bulk_edit.py:1290 +#: dcim/forms/bulk_import.py:59 dcim/forms/bulk_import.py:73 +#: dcim/forms/bulk_import.py:136 dcim/forms/bulk_import.py:503 +#: dcim/forms/bulk_import.py:770 dcim/forms/bulk_import.py:1021 +#: dcim/forms/filtersets.py:224 dcim/forms/model_forms.py:73 +#: dcim/forms/model_forms.py:94 dcim/forms/model_forms.py:172 +#: dcim/forms/model_forms.py:954 dcim/forms/model_forms.py:1295 +#: dcim/forms/object_import.py:181 dcim/tables/devices.py:654 +#: extras/tables/tables.py:203 ipam/tables/fhrp.py:59 ipam/tables/ip.py:374 +#: ipam/tables/services.py:44 templates/dcim/interface.html:97 +#: templates/dcim/interface.html:317 templates/dcim/location.html:44 +#: templates/dcim/region.html:38 templates/dcim/sitegroup.html:38 +#: templates/ipam/service.html:31 templates/tenancy/contactgroup.html:32 +#: templates/tenancy/tenantgroup.html:40 +#: templates/virtualization/vminterface.html:42 +#: templates/wireless/wirelesslangroup.html:38 tenancy/forms/bulk_edit.py:26 +#: tenancy/forms/bulk_edit.py:60 tenancy/forms/bulk_import.py:24 +#: tenancy/forms/bulk_import.py:58 tenancy/forms/model_forms.py:27 +#: tenancy/forms/model_forms.py:72 virtualization/forms/bulk_edit.py:204 +#: virtualization/forms/bulk_import.py:150 +#: virtualization/tables/virtualmachines.py:136 wireless/forms/bulk_edit.py:23 +#: wireless/forms/bulk_import.py:21 wireless/forms/model_forms.py:20 +msgid "Parent" +msgstr "" + +#: dcim/choices.py:141 +msgid "Child" +msgstr "" + +#: dcim/choices.py:155 templates/dcim/device.html:362 +#: templates/dcim/rack.html:188 templates/dcim/rack_elevation_list.html:22 +#: templates/dcim/rackreservation.html:84 +msgid "Front" +msgstr "" + +#: dcim/choices.py:156 templates/dcim/device.html:368 +#: templates/dcim/rack.html:194 templates/dcim/rack_elevation_list.html:23 +#: templates/dcim/rackreservation.html:90 +msgid "Rear" +msgstr "" + +#: dcim/choices.py:175 dcim/choices.py:221 virtualization/choices.py:46 +msgid "Staged" +msgstr "" + +#: dcim/choices.py:177 +msgid "Inventory" +msgstr "" + +#: dcim/choices.py:193 +msgid "Front to rear" +msgstr "" + +#: dcim/choices.py:194 +msgid "Rear to front" +msgstr "" + +#: dcim/choices.py:195 +msgid "Left to right" +msgstr "" + +#: dcim/choices.py:196 +msgid "Right to left" +msgstr "" + +#: dcim/choices.py:197 +msgid "Side to rear" +msgstr "" + +#: dcim/choices.py:198 dcim/choices.py:1215 +msgid "Passive" +msgstr "" + +#: dcim/choices.py:199 +msgid "Mixed" +msgstr "" + +#: dcim/choices.py:443 dcim/choices.py:680 +msgid "NEMA (Non-locking)" +msgstr "" + +#: dcim/choices.py:465 dcim/choices.py:702 +msgid "NEMA (Locking)" +msgstr "" + +#: dcim/choices.py:488 dcim/choices.py:725 +msgid "California Style" +msgstr "" + +#: dcim/choices.py:496 +msgid "International/ITA" +msgstr "" + +#: dcim/choices.py:526 dcim/choices.py:755 +msgid "Proprietary" +msgstr "" + +#: dcim/choices.py:534 dcim/choices.py:764 dcim/choices.py:1131 +#: dcim/choices.py:1133 dcim/choices.py:1338 dcim/choices.py:1340 +#: netbox/navigation/menu.py:188 +msgid "Other" +msgstr "" + +#: dcim/choices.py:733 +msgid "ITA/International" +msgstr "" + +#: dcim/choices.py:794 +msgid "Physical" +msgstr "" + +#: dcim/choices.py:795 dcim/choices.py:949 +msgid "Virtual" +msgstr "" + +#: dcim/choices.py:796 dcim/choices.py:1019 dcim/forms/bulk_edit.py:1398 +#: dcim/forms/filtersets.py:1225 dcim/forms/model_forms.py:880 +#: dcim/forms/model_forms.py:1189 netbox/navigation/menu.py:128 +#: netbox/navigation/menu.py:132 templates/dcim/interface.html:213 +msgid "Wireless" +msgstr "" + +#: dcim/choices.py:947 +msgid "Virtual interfaces" +msgstr "" + +#: dcim/choices.py:950 dcim/forms/bulk_edit.py:1295 +#: dcim/forms/bulk_import.py:777 dcim/forms/model_forms.py:868 +#: dcim/tables/devices.py:658 templates/dcim/interface.html:101 +#: templates/virtualization/vminterface.html:46 +#: virtualization/forms/bulk_edit.py:209 +#: virtualization/forms/bulk_import.py:157 +#: virtualization/tables/virtualmachines.py:140 +msgid "Bridge" +msgstr "" + +#: dcim/choices.py:951 +msgid "Link Aggregation Group (LAG)" +msgstr "" + +#: dcim/choices.py:955 +msgid "Ethernet (fixed)" +msgstr "" + +#: dcim/choices.py:969 +msgid "Ethernet (modular)" +msgstr "" + +#: dcim/choices.py:1005 +msgid "Ethernet (backplane)" +msgstr "" + +#: dcim/choices.py:1033 +msgid "Cellular" +msgstr "" + +#: dcim/choices.py:1080 dcim/forms/filtersets.py:299 +#: dcim/forms/filtersets.py:729 dcim/forms/filtersets.py:869 +#: dcim/forms/filtersets.py:1417 templates/dcim/inventoryitem.html:53 +#: templates/dcim/virtualchassis_edit.html:55 +msgid "Serial" +msgstr "" + +#: dcim/choices.py:1095 +msgid "Coaxial" +msgstr "" + +#: dcim/choices.py:1112 +msgid "Stacking" +msgstr "" + +#: dcim/choices.py:1162 +msgid "Half" +msgstr "" + +#: dcim/choices.py:1163 +msgid "Full" +msgstr "" + +#: dcim/choices.py:1164 wireless/choices.py:480 +msgid "Auto" +msgstr "" + +#: dcim/choices.py:1175 +msgid "Access" +msgstr "" + +#: dcim/choices.py:1176 ipam/tables/vlans.py:168 ipam/tables/vlans.py:213 +#: templates/dcim/inc/interface_vlans_table.html:7 +msgid "Tagged" +msgstr "" + +#: dcim/choices.py:1177 +msgid "Tagged (All)" +msgstr "" + +#: dcim/choices.py:1206 +msgid "IEEE Standard" +msgstr "" + +#: dcim/choices.py:1217 +msgid "Passive 24V (2-pair)" +msgstr "" + +#: dcim/choices.py:1218 +msgid "Passive 24V (4-pair)" +msgstr "" + +#: dcim/choices.py:1219 +msgid "Passive 48V (2-pair)" +msgstr "" + +#: dcim/choices.py:1220 +msgid "Passive 48V (4-pair)" +msgstr "" + +#: dcim/choices.py:1282 dcim/choices.py:1378 +msgid "Copper" +msgstr "" + +#: dcim/choices.py:1305 +msgid "Fiber Optic" +msgstr "" + +#: dcim/choices.py:1394 +msgid "Fiber" +msgstr "" + +#: dcim/choices.py:1407 dcim/forms/bulk_edit.py:859 +#: dcim/forms/bulk_edit.py:1242 dcim/forms/bulk_edit.py:1260 +#: dcim/tables/racks.py:89 extras/forms/model_forms.py:489 +#: netbox/navigation/menu.py:257 netbox/navigation/menu.py:261 +msgid "Power" +msgstr "" + +#: dcim/choices.py:1418 dcim/forms/filtersets.py:1132 +msgid "Connected" +msgstr "" + +#: dcim/choices.py:1437 +msgid "Kilometers" +msgstr "" + +#: dcim/choices.py:1438 templates/dcim/cable_trace.html:62 +msgid "Meters" +msgstr "" + +#: dcim/choices.py:1439 +msgid "Centimeters" +msgstr "" + +#: dcim/choices.py:1440 +msgid "Miles" +msgstr "" + +#: dcim/choices.py:1441 templates/dcim/cable_trace.html:63 +msgid "Feet" +msgstr "" + +#: dcim/choices.py:1457 templates/dcim/device.html:349 +#: templates/dcim/rack.html:164 +msgid "Kilograms" +msgstr "" + +#: dcim/choices.py:1458 +msgid "Grams" +msgstr "" + +#: dcim/choices.py:1459 templates/dcim/rack.html:165 +msgid "Pounds" +msgstr "" + +#: dcim/choices.py:1460 +msgid "Ounces" +msgstr "" + +#: dcim/choices.py:1506 tenancy/choices.py:17 +msgid "Primary" +msgstr "" + +#: dcim/choices.py:1507 +msgid "Redundant" +msgstr "" + +#: dcim/choices.py:1528 +msgid "Single phase" +msgstr "" + +#: dcim/choices.py:1529 +msgid "Three-phase" +msgstr "" + +#: dcim/filtersets.py:78 +msgid "Parent region (ID)" +msgstr "" + +#: dcim/filtersets.py:84 +msgid "Parent region (slug)" +msgstr "" + +#: dcim/filtersets.py:95 +msgid "Parent site group (ID)" +msgstr "" + +#: dcim/filtersets.py:101 +msgid "Parent site group (slug)" +msgstr "" + +#: dcim/filtersets.py:130 ipam/filtersets.py:792 ipam/filtersets.py:925 +msgid "Group (ID)" +msgstr "" + +#: dcim/filtersets.py:136 +msgid "Group (slug)" +msgstr "" + +#: dcim/filtersets.py:142 dcim/filtersets.py:147 +msgid "AS (ID)" +msgstr "" + +#: dcim/filtersets.py:215 dcim/filtersets.py:290 dcim/filtersets.py:388 +#: dcim/filtersets.py:909 dcim/filtersets.py:1215 dcim/filtersets.py:1883 +msgid "Location (ID)" +msgstr "" + +#: dcim/filtersets.py:222 dcim/filtersets.py:297 dcim/filtersets.py:395 +#: dcim/filtersets.py:1221 extras/filtersets.py:416 +msgid "Location (slug)" +msgstr "" + +#: dcim/filtersets.py:311 dcim/filtersets.py:762 dcim/filtersets.py:846 +#: dcim/filtersets.py:1621 ipam/filtersets.py:346 ipam/filtersets.py:458 +#: ipam/filtersets.py:935 virtualization/filtersets.py:206 +msgid "Role (ID)" +msgstr "" + +#: dcim/filtersets.py:317 dcim/filtersets.py:768 dcim/filtersets.py:852 +#: dcim/filtersets.py:1627 extras/filtersets.py:432 ipam/filtersets.py:352 +#: ipam/filtersets.py:464 ipam/filtersets.py:941 +#: virtualization/filtersets.py:212 +msgid "Role (slug)" +msgstr "" + +#: dcim/filtersets.py:345 dcim/filtersets.py:914 dcim/filtersets.py:1226 +#: dcim/filtersets.py:1944 +msgid "Rack (ID)" +msgstr "" + +#: dcim/filtersets.py:399 extras/filtersets.py:203 extras/filtersets.py:247 +#: extras/filtersets.py:287 extras/filtersets.py:582 +msgid "User (ID)" +msgstr "" + +#: dcim/filtersets.py:405 extras/filtersets.py:209 extras/filtersets.py:253 +#: extras/filtersets.py:293 users/filtersets.py:80 users/filtersets.py:140 +msgid "User (name)" +msgstr "" + +#: dcim/filtersets.py:433 dcim/filtersets.py:559 dcim/filtersets.py:752 +#: dcim/filtersets.py:803 dcim/filtersets.py:825 dcim/filtersets.py:1118 +#: dcim/filtersets.py:1611 +msgid "Manufacturer (ID)" +msgstr "" + +#: dcim/filtersets.py:439 dcim/filtersets.py:565 dcim/filtersets.py:758 +#: dcim/filtersets.py:809 dcim/filtersets.py:831 dcim/filtersets.py:1124 +#: dcim/filtersets.py:1617 +msgid "Manufacturer (slug)" +msgstr "" + +#: dcim/filtersets.py:443 +msgid "Default platform (ID)" +msgstr "" + +#: dcim/filtersets.py:449 +msgid "Default platform (slug)" +msgstr "" + +#: dcim/filtersets.py:452 dcim/forms/filtersets.py:448 +msgid "Has a front image" +msgstr "" + +#: dcim/filtersets.py:456 dcim/forms/filtersets.py:455 +msgid "Has a rear image" +msgstr "" + +#: dcim/filtersets.py:461 dcim/filtersets.py:569 dcim/filtersets.py:967 +#: dcim/forms/filtersets.py:462 dcim/forms/filtersets.py:558 +#: dcim/forms/filtersets.py:768 +msgid "Has console ports" +msgstr "" + +#: dcim/filtersets.py:465 dcim/filtersets.py:573 dcim/filtersets.py:971 +#: dcim/forms/filtersets.py:469 dcim/forms/filtersets.py:565 +#: dcim/forms/filtersets.py:775 +msgid "Has console server ports" +msgstr "" + +#: dcim/filtersets.py:469 dcim/filtersets.py:577 dcim/filtersets.py:975 +#: dcim/forms/filtersets.py:476 dcim/forms/filtersets.py:572 +#: dcim/forms/filtersets.py:782 +msgid "Has power ports" +msgstr "" + +#: dcim/filtersets.py:473 dcim/filtersets.py:581 dcim/filtersets.py:979 +#: dcim/forms/filtersets.py:483 dcim/forms/filtersets.py:579 +#: dcim/forms/filtersets.py:789 +msgid "Has power outlets" +msgstr "" + +#: dcim/filtersets.py:477 dcim/filtersets.py:585 dcim/filtersets.py:983 +#: dcim/forms/filtersets.py:490 dcim/forms/filtersets.py:586 +#: dcim/forms/filtersets.py:796 +msgid "Has interfaces" +msgstr "" + +#: dcim/filtersets.py:481 dcim/filtersets.py:589 dcim/filtersets.py:987 +#: dcim/forms/filtersets.py:497 dcim/forms/filtersets.py:593 +#: dcim/forms/filtersets.py:803 +msgid "Has pass-through ports" +msgstr "" + +#: dcim/filtersets.py:485 dcim/filtersets.py:991 dcim/forms/filtersets.py:511 +msgid "Has module bays" +msgstr "" + +#: dcim/filtersets.py:489 dcim/filtersets.py:995 dcim/forms/filtersets.py:504 +msgid "Has device bays" +msgstr "" + +#: dcim/filtersets.py:493 dcim/forms/filtersets.py:518 +msgid "Has inventory items" +msgstr "" + +#: dcim/filtersets.py:636 dcim/filtersets.py:841 dcim/filtersets.py:1247 +msgid "Device type (ID)" +msgstr "" + +#: dcim/filtersets.py:649 dcim/filtersets.py:1129 +msgid "Module type (ID)" +msgstr "" + +#: dcim/filtersets.py:748 dcim/filtersets.py:1607 +msgid "Parent inventory item (ID)" +msgstr "" + +#: dcim/filtersets.py:791 dcim/filtersets.py:813 dcim/filtersets.py:963 +#: virtualization/filtersets.py:234 +msgid "Config template (ID)" +msgstr "" + +#: dcim/filtersets.py:837 +msgid "Device type (slug)" +msgstr "" + +#: dcim/filtersets.py:857 +msgid "Parent Device (ID)" +msgstr "" + +#: dcim/filtersets.py:861 virtualization/filtersets.py:216 +msgid "Platform (ID)" +msgstr "" + +#: dcim/filtersets.py:867 extras/filtersets.py:443 +#: virtualization/filtersets.py:222 +msgid "Platform (slug)" +msgstr "" + +#: dcim/filtersets.py:903 dcim/filtersets.py:1210 dcim/filtersets.py:1705 +#: dcim/filtersets.py:1877 dcim/filtersets.py:1935 +msgid "Site name (slug)" +msgstr "" + +#: dcim/filtersets.py:918 +msgid "VM cluster (ID)" +msgstr "" + +#: dcim/filtersets.py:924 +msgid "Device model (slug)" +msgstr "" + +#: dcim/filtersets.py:935 dcim/forms/bulk_edit.py:421 +msgid "Is full depth" +msgstr "" + +#: dcim/filtersets.py:939 dcim/forms/common.py:18 dcim/forms/filtersets.py:738 +#: dcim/forms/filtersets.py:1276 dcim/models/device_components.py:520 +#: virtualization/filtersets.py:226 virtualization/filtersets.py:292 +#: virtualization/forms/filtersets.py:165 +#: virtualization/forms/filtersets.py:211 +msgid "MAC address" +msgstr "" + +#: dcim/filtersets.py:946 dcim/forms/filtersets.py:747 +#: dcim/forms/filtersets.py:834 virtualization/filtersets.py:230 +#: virtualization/forms/filtersets.py:169 +msgid "Has a primary IP" +msgstr "" + +#: dcim/filtersets.py:950 +msgid "Has an out-of-band IP" +msgstr "" + +#: dcim/filtersets.py:955 +msgid "Virtual chassis (ID)" +msgstr "" + +#: dcim/filtersets.py:959 +msgid "Is a virtual chassis member" +msgstr "" + +#: dcim/filtersets.py:1000 +msgid "Primary IPv4 (ID)" +msgstr "" + +#: dcim/filtersets.py:1005 +msgid "Primary IPv6 (ID)" +msgstr "" + +#: dcim/filtersets.py:1010 +msgid "OOB IP (ID)" +msgstr "" + +#: dcim/filtersets.py:1135 +msgid "Module type (model)" +msgstr "" + +#: dcim/filtersets.py:1141 +msgid "Module Bay (ID)" +msgstr "" + +#: dcim/filtersets.py:1145 dcim/filtersets.py:1236 ipam/filtersets.py:567 +#: ipam/filtersets.py:802 ipam/filtersets.py:1010 ipam/filtersets.py:1143 +#: virtualization/filtersets.py:157 +msgid "Device (ID)" +msgstr "" + +#: dcim/filtersets.py:1232 +msgid "Rack (name)" +msgstr "" + +#: dcim/filtersets.py:1242 ipam/filtersets.py:562 ipam/filtersets.py:797 +#: ipam/filtersets.py:1016 ipam/filtersets.py:1138 +msgid "Device (name)" +msgstr "" + +#: dcim/filtersets.py:1253 +msgid "Device type (model)" +msgstr "" + +#: dcim/filtersets.py:1258 dcim/filtersets.py:1281 +msgid "Device role (ID)" +msgstr "" + +#: dcim/filtersets.py:1264 dcim/filtersets.py:1287 +msgid "Device role (slug)" +msgstr "" + +#: dcim/filtersets.py:1269 +msgid "Virtual Chassis (ID)" +msgstr "" + +#: dcim/filtersets.py:1275 dcim/forms/filtersets.py:105 +#: dcim/tables/devices.py:235 netbox/navigation/menu.py:67 +#: templates/dcim/device.html:140 templates/dcim/device_edit.html:93 +#: templates/dcim/virtualchassis.html:20 +#: templates/dcim/virtualchassis_add.html:8 +#: templates/dcim/virtualchassis_edit.html:25 +msgid "Virtual Chassis" +msgstr "" + +#: dcim/filtersets.py:1307 +msgid "Module (ID)" +msgstr "" + +#: dcim/filtersets.py:1411 ipam/forms/bulk_import.py:191 +#: ipam/forms/bulk_import.py:568 +msgid "Assigned VLAN" +msgstr "" + +#: dcim/filtersets.py:1415 +msgid "Assigned VID" +msgstr "" + +#: dcim/filtersets.py:1420 dcim/forms/bulk_edit.py:1374 +#: dcim/forms/bulk_import.py:828 dcim/forms/filtersets.py:1319 +#: dcim/forms/model_forms.py:1174 dcim/models/device_components.py:709 +#: dcim/tables/devices.py:625 ipam/filtersets.py:281 ipam/filtersets.py:292 +#: ipam/filtersets.py:448 ipam/filtersets.py:540 ipam/filtersets.py:551 +#: ipam/forms/bulk_edit.py:228 ipam/forms/bulk_edit.py:283 +#: ipam/forms/bulk_edit.py:325 ipam/forms/bulk_import.py:159 +#: ipam/forms/bulk_import.py:245 ipam/forms/bulk_import.py:281 +#: ipam/forms/filtersets.py:70 ipam/forms/filtersets.py:171 +#: ipam/forms/filtersets.py:299 ipam/forms/model_forms.py:61 +#: ipam/forms/model_forms.py:205 ipam/forms/model_forms.py:248 +#: ipam/forms/model_forms.py:292 ipam/forms/model_forms.py:414 +#: ipam/forms/model_forms.py:428 ipam/forms/model_forms.py:442 +#: ipam/models/ip.py:232 ipam/models/ip.py:511 ipam/models/ip.py:719 +#: ipam/models/vrfs.py:62 ipam/tables/ip.py:241 ipam/tables/ip.py:306 +#: ipam/tables/ip.py:356 ipam/tables/ip.py:445 +#: templates/dcim/interface.html:134 templates/ipam/ipaddress.html:21 +#: templates/ipam/iprange.html:43 templates/ipam/prefix.html:19 +#: templates/ipam/vrf.html:7 templates/ipam/vrf.html:14 +#: templates/virtualization/vminterface.html:50 +#: virtualization/forms/bulk_edit.py:258 +#: virtualization/forms/bulk_import.py:170 +#: virtualization/forms/filtersets.py:216 +#: virtualization/forms/model_forms.py:326 +#: virtualization/models/virtualmachines.py:286 +#: virtualization/tables/virtualmachines.py:118 +msgid "VRF" +msgstr "" + +#: dcim/filtersets.py:1426 ipam/filtersets.py:287 ipam/filtersets.py:298 +#: ipam/filtersets.py:454 ipam/filtersets.py:546 ipam/filtersets.py:557 +msgid "VRF (RD)" +msgstr "" + +#: dcim/filtersets.py:1431 ipam/filtersets.py:958 ipam/filtersets.py:1106 +msgid "L2VPN (ID)" +msgstr "" + +#: dcim/filtersets.py:1437 dcim/forms/filtersets.py:1324 +#: dcim/tables/devices.py:579 ipam/filtersets.py:964 +#: ipam/forms/bulk_import.py:540 ipam/forms/filtersets.py:501 +#: ipam/forms/filtersets.py:565 ipam/forms/model_forms.py:779 +#: ipam/forms/model_forms.py:797 ipam/models/l2vpn.py:63 +#: ipam/tables/l2vpn.py:55 ipam/tables/vlans.py:133 +#: templates/dcim/interface.html:109 templates/ipam/l2vpntermination.html:15 +#: templates/ipam/vlan.html:69 virtualization/forms/filtersets.py:221 +msgid "L2VPN" +msgstr "" + +#: dcim/filtersets.py:1469 +msgid "Virtual Chassis Interfaces for Device" +msgstr "" + +#: dcim/filtersets.py:1474 +msgid "Virtual Chassis Interfaces for Device (ID)" +msgstr "" + +#: dcim/filtersets.py:1478 +msgid "Kind of interface" +msgstr "" + +#: dcim/filtersets.py:1483 virtualization/filtersets.py:284 +msgid "Parent interface (ID)" +msgstr "" + +#: dcim/filtersets.py:1488 virtualization/filtersets.py:289 +msgid "Bridged interface (ID)" +msgstr "" + +#: dcim/filtersets.py:1493 +msgid "LAG interface (ID)" +msgstr "" + +#: dcim/filtersets.py:1662 +msgid "Master (ID)" +msgstr "" + +#: dcim/filtersets.py:1668 +msgid "Master (name)" +msgstr "" + +#: dcim/filtersets.py:1710 tenancy/filtersets.py:208 +msgid "Tenant (ID)" +msgstr "" + +#: dcim/filtersets.py:1716 extras/filtersets.py:492 tenancy/filtersets.py:214 +msgid "Tenant (slug)" +msgstr "" + +#: dcim/filtersets.py:1751 dcim/forms/filtersets.py:983 +msgid "Unterminated" +msgstr "" + +#: dcim/filtersets.py:1939 +msgid "Power panel (ID)" +msgstr "" + +#: dcim/forms/bulk_create.py:40 extras/forms/filtersets.py:385 +#: extras/forms/mixins.py:82 extras/forms/model_forms.py:341 +#: extras/forms/model_forms.py:392 netbox/forms/base.py:71 +#: netbox/tables/columns.py:448 +#: templates/circuits/inc/circuit_termination.html:119 +#: templates/generic/bulk_edit.html:81 templates/inc/panels/tags.html:5 +#: utilities/forms/fields/fields.py:81 +msgid "Tags" +msgstr "" + +#: dcim/forms/bulk_create.py:112 dcim/forms/filtersets.py:1381 +#: dcim/forms/model_forms.py:422 dcim/forms/model_forms.py:467 +#: dcim/forms/object_create.py:179 dcim/forms/object_create.py:319 +#: dcim/tables/devices.py:198 dcim/tables/devices.py:703 +#: dcim/tables/devicetypes.py:242 templates/dcim/device.html:62 +#: templates/dcim/device.html:146 templates/dcim/modulebay.html:35 +#: templates/dcim/virtualchassis.html:59 +#: templates/dcim/virtualchassis_edit.html:56 +msgid "Position" +msgstr "" + +#: dcim/forms/bulk_create.py:114 +msgid "" +"Alphanumeric ranges are supported. (Must match the number of names being " +"created.)" +msgstr "" + +#: dcim/forms/bulk_edit.py:115 dcim/forms/bulk_import.py:99 +#: dcim/forms/model_forms.py:120 dcim/tables/sites.py:89 ipam/filtersets.py:931 +#: ipam/forms/bulk_edit.py:530 ipam/forms/bulk_import.py:447 +#: ipam/forms/model_forms.py:511 ipam/tables/fhrp.py:67 +#: ipam/tables/vlans.py:118 ipam/tables/vlans.py:221 +#: templates/dcim/interface.html:290 templates/dcim/site.html:43 +#: templates/ipam/inc/panels/fhrp_groups.html:10 templates/ipam/vlan.html:30 +#: templates/tenancy/contact.html:22 templates/tenancy/tenant.html:21 +#: templates/users/group.html:6 templates/users/group.html:14 +#: templates/virtualization/cluster.html:32 +#: templates/wireless/wirelesslan.html:19 tenancy/forms/bulk_edit.py:42 +#: tenancy/forms/bulk_edit.py:93 tenancy/forms/bulk_import.py:40 +#: tenancy/forms/bulk_import.py:81 tenancy/forms/filtersets.py:48 +#: tenancy/forms/filtersets.py:78 tenancy/forms/filtersets.py:98 +#: tenancy/forms/model_forms.py:49 tenancy/forms/model_forms.py:105 +#: tenancy/forms/model_forms.py:127 tenancy/tables/contacts.py:60 +#: tenancy/tables/tenants.py:42 users/filtersets.py:42 users/filtersets.py:145 +#: users/forms/filtersets.py:34 users/forms/filtersets.py:40 +#: users/forms/filtersets.py:82 virtualization/forms/bulk_edit.py:62 +#: virtualization/forms/bulk_import.py:46 virtualization/forms/filtersets.py:81 +#: virtualization/forms/model_forms.py:68 virtualization/tables/clusters.py:70 +#: wireless/forms/bulk_edit.py:47 wireless/forms/bulk_import.py:36 +#: wireless/forms/filtersets.py:45 wireless/forms/model_forms.py:41 +#: wireless/tables/wirelesslan.py:48 +msgid "Group" +msgstr "" + +#: dcim/forms/bulk_edit.py:130 +msgid "Contact name" +msgstr "" + +#: dcim/forms/bulk_edit.py:135 +msgid "Contact phone" +msgstr "" + +#: dcim/forms/bulk_edit.py:141 +msgid "Contact E-mail" +msgstr "" + +#: dcim/forms/bulk_edit.py:144 dcim/forms/bulk_import.py:122 +#: dcim/forms/model_forms.py:131 +msgid "Time zone" +msgstr "" + +#: dcim/forms/bulk_edit.py:266 dcim/forms/bulk_edit.py:1152 +#: dcim/forms/bulk_edit.py:1539 dcim/forms/bulk_import.py:199 +#: dcim/forms/bulk_import.py:1009 dcim/forms/filtersets.py:296 +#: dcim/forms/filtersets.py:697 dcim/forms/filtersets.py:1408 +#: dcim/forms/model_forms.py:224 dcim/forms/model_forms.py:962 +#: dcim/forms/model_forms.py:1303 dcim/forms/object_import.py:186 +#: dcim/tables/devices.py:202 dcim/tables/devices.py:811 +#: dcim/tables/devices.py:922 dcim/tables/devicetypes.py:300 +#: dcim/tables/racks.py:69 extras/filtersets.py:426 ipam/forms/bulk_edit.py:247 +#: ipam/forms/bulk_edit.py:296 ipam/forms/bulk_edit.py:344 +#: ipam/forms/bulk_edit.py:548 ipam/forms/bulk_import.py:199 +#: ipam/forms/bulk_import.py:264 ipam/forms/bulk_import.py:300 +#: ipam/forms/bulk_import.py:466 ipam/forms/filtersets.py:236 +#: ipam/forms/filtersets.py:282 ipam/forms/filtersets.py:349 +#: ipam/forms/filtersets.py:492 ipam/forms/model_forms.py:189 +#: ipam/forms/model_forms.py:224 ipam/forms/model_forms.py:251 +#: ipam/forms/model_forms.py:649 ipam/tables/ip.py:257 ipam/tables/ip.py:313 +#: ipam/tables/ip.py:363 ipam/tables/vlans.py:126 ipam/tables/vlans.py:230 +#: templates/dcim/device.html:204 +#: templates/dcim/inc/panels/inventory_items.html:12 +#: templates/dcim/interface.html:227 templates/dcim/inventoryitem.html:37 +#: templates/dcim/rack.html:57 templates/ipam/ipaddress.html:44 +#: templates/ipam/iprange.html:53 templates/ipam/prefix.html:78 +#: templates/ipam/role.html:20 templates/ipam/vlan.html:55 +#: templates/virtualization/virtualmachine.html:26 +#: templates/wireless/inc/wirelesslink_interface.html:20 +#: tenancy/forms/bulk_edit.py:141 tenancy/forms/filtersets.py:108 +#: tenancy/forms/model_forms.py:142 tenancy/tables/contacts.py:102 +#: virtualization/forms/bulk_edit.py:142 +#: virtualization/forms/bulk_import.py:105 +#: virtualization/forms/filtersets.py:150 +#: virtualization/forms/model_forms.py:197 +#: virtualization/tables/virtualmachines.py:63 +msgid "Role" +msgstr "" + +#: dcim/forms/bulk_edit.py:273 dcim/forms/bulk_edit.py:605 +#: dcim/forms/bulk_edit.py:654 templates/dcim/device.html:123 +#: templates/dcim/module.html:75 templates/dcim/modulebay.html:69 +#: templates/dcim/rack.html:65 +msgid "Serial Number" +msgstr "" + +#: dcim/forms/bulk_edit.py:276 dcim/forms/filtersets.py:303 +#: dcim/forms/filtersets.py:733 dcim/forms/filtersets.py:873 +#: dcim/forms/filtersets.py:1421 +msgid "Asset tag" +msgstr "" + +#: dcim/forms/bulk_edit.py:286 dcim/forms/bulk_import.py:212 +#: dcim/forms/filtersets.py:288 templates/dcim/rack.html:98 +#: templates/dcim/rack_edit.html:48 +msgid "Width" +msgstr "" + +#: dcim/forms/bulk_edit.py:292 +msgid "Height (U)" +msgstr "" + +#: dcim/forms/bulk_edit.py:297 +msgid "Descending units" +msgstr "" + +#: dcim/forms/bulk_edit.py:300 +msgid "Outer width" +msgstr "" + +#: dcim/forms/bulk_edit.py:305 +msgid "Outer depth" +msgstr "" + +#: dcim/forms/bulk_edit.py:310 dcim/forms/bulk_import.py:217 +msgid "Outer unit" +msgstr "" + +#: dcim/forms/bulk_edit.py:315 +msgid "Mounting depth" +msgstr "" + +#: dcim/forms/bulk_edit.py:320 dcim/forms/bulk_edit.py:349 +#: dcim/forms/bulk_edit.py:434 dcim/forms/bulk_edit.py:457 +#: dcim/forms/bulk_edit.py:473 dcim/forms/bulk_edit.py:493 +#: dcim/forms/bulk_import.py:324 dcim/forms/bulk_import.py:350 +#: dcim/forms/filtersets.py:248 dcim/forms/filtersets.py:308 +#: dcim/forms/filtersets.py:332 dcim/forms/filtersets.py:420 +#: dcim/forms/filtersets.py:525 dcim/forms/filtersets.py:544 +#: dcim/forms/filtersets.py:600 dcim/forms/model_forms.py:337 +#: dcim/tables/devicetypes.py:103 dcim/tables/modules.py:35 +#: dcim/tables/racks.py:103 extras/forms/bulk_edit.py:44 +#: extras/forms/bulk_edit.py:102 extras/forms/bulk_edit.py:152 +#: extras/forms/bulk_edit.py:256 extras/forms/filtersets.py:62 +#: extras/forms/filtersets.py:130 extras/forms/filtersets.py:217 +#: ipam/forms/bulk_edit.py:189 templates/dcim/device.html:346 +#: templates/dcim/devicetype.html:52 templates/dcim/moduletype.html:31 +#: templates/dcim/rack_edit.html:60 templates/dcim/rack_edit.html:63 +#: templates/extras/configcontext.html:18 templates/extras/customlink.html:26 +#: templates/extras/savedfilter.html:34 templates/ipam/role.html:33 +msgid "Weight" +msgstr "" + +#: dcim/forms/bulk_edit.py:325 dcim/forms/filtersets.py:313 +msgid "Max weight" +msgstr "" + +#: dcim/forms/bulk_edit.py:330 dcim/forms/bulk_edit.py:439 +#: dcim/forms/bulk_edit.py:478 dcim/forms/bulk_import.py:223 +#: dcim/forms/bulk_import.py:329 dcim/forms/bulk_import.py:355 +#: dcim/forms/filtersets.py:318 dcim/forms/filtersets.py:529 +#: dcim/forms/filtersets.py:604 +msgid "Weight unit" +msgstr "" + +#: dcim/forms/bulk_edit.py:344 dcim/forms/bulk_edit.py:800 +#: dcim/forms/bulk_import.py:262 dcim/forms/bulk_import.py:265 +#: dcim/forms/bulk_import.py:490 dcim/forms/bulk_import.py:1286 +#: dcim/forms/bulk_import.py:1290 dcim/forms/filtersets.py:100 +#: dcim/forms/filtersets.py:336 dcim/forms/filtersets.py:350 +#: dcim/forms/filtersets.py:388 dcim/forms/filtersets.py:692 +#: dcim/forms/filtersets.py:941 dcim/forms/filtersets.py:1072 +#: dcim/forms/model_forms.py:241 dcim/forms/model_forms.py:413 +#: dcim/forms/model_forms.py:661 dcim/forms/object_create.py:366 +#: dcim/tables/devices.py:194 dcim/tables/power.py:70 dcim/tables/racks.py:148 +#: ipam/forms/bulk_edit.py:466 ipam/forms/filtersets.py:430 +#: ipam/forms/model_forms.py:573 templates/dcim/device.html:47 +#: templates/dcim/inc/cable_termination.html:16 +#: templates/dcim/powerfeed.html:31 templates/dcim/rack.html:13 +#: templates/dcim/rack/base.html:4 templates/dcim/rack_edit.html:8 +#: templates/dcim/rackreservation.html:19 +#: templates/dcim/rackreservation.html:38 +#: virtualization/forms/model_forms.py:115 +msgid "Rack" +msgstr "" + +#: dcim/forms/bulk_edit.py:346 dcim/forms/bulk_edit.py:623 +#: dcim/forms/filtersets.py:245 dcim/forms/filtersets.py:329 +#: dcim/forms/filtersets.py:414 dcim/forms/filtersets.py:539 +#: dcim/forms/filtersets.py:646 dcim/forms/filtersets.py:846 +#: dcim/forms/model_forms.py:588 dcim/forms/model_forms.py:1373 +#: templates/dcim/device_edit.html:20 templates/dcim/inventoryitem_edit.html:23 +msgid "Hardware" +msgstr "" + +#: dcim/forms/bulk_edit.py:400 dcim/forms/bulk_edit.py:464 +#: dcim/forms/bulk_edit.py:528 dcim/forms/bulk_edit.py:552 +#: dcim/forms/bulk_edit.py:633 dcim/forms/bulk_edit.py:1157 +#: dcim/forms/bulk_edit.py:1544 dcim/forms/bulk_import.py:311 +#: dcim/forms/bulk_import.py:345 dcim/forms/bulk_import.py:387 +#: dcim/forms/bulk_import.py:423 dcim/forms/bulk_import.py:1015 +#: dcim/forms/filtersets.py:425 dcim/forms/filtersets.py:549 +#: dcim/forms/filtersets.py:625 dcim/forms/filtersets.py:702 +#: dcim/forms/filtersets.py:851 dcim/forms/filtersets.py:1414 +#: dcim/forms/model_forms.py:274 dcim/forms/model_forms.py:288 +#: dcim/forms/model_forms.py:330 dcim/forms/model_forms.py:370 +#: dcim/forms/model_forms.py:967 dcim/forms/model_forms.py:1308 +#: dcim/forms/object_import.py:192 dcim/tables/devices.py:129 +#: dcim/tables/devices.py:205 dcim/tables/devices.py:925 +#: dcim/tables/devicetypes.py:81 dcim/tables/devicetypes.py:304 +#: dcim/tables/modules.py:20 dcim/tables/modules.py:60 +#: templates/dcim/devicetype.html:17 templates/dcim/inventoryitem.html:45 +#: templates/dcim/manufacturer.html:34 templates/dcim/modulebay.html:61 +#: templates/dcim/moduletype.html:15 templates/dcim/platform.html:40 +msgid "Manufacturer" +msgstr "" + +#: dcim/forms/bulk_edit.py:405 dcim/forms/bulk_import.py:317 +#: dcim/forms/filtersets.py:430 dcim/forms/model_forms.py:292 +msgid "Default platform" +msgstr "" + +#: dcim/forms/bulk_edit.py:410 dcim/forms/bulk_edit.py:469 +#: dcim/forms/filtersets.py:433 dcim/forms/filtersets.py:553 +msgid "Part number" +msgstr "" + +#: dcim/forms/bulk_edit.py:414 +msgid "U height" +msgstr "" + +#: dcim/forms/bulk_edit.py:426 +msgid "Exclude from utilization" +msgstr "" + +#: dcim/forms/bulk_edit.py:429 dcim/forms/bulk_edit.py:598 +#: dcim/forms/bulk_import.py:517 dcim/forms/filtersets.py:442 +#: dcim/forms/filtersets.py:724 templates/dcim/device.html:117 +#: templates/dcim/devicetype.html:68 +msgid "Airflow" +msgstr "" + +#: dcim/forms/bulk_edit.py:453 dcim/forms/model_forms.py:303 +#: dcim/tables/devicetypes.py:78 templates/dcim/device.html:107 +#: templates/dcim/devicebay.html:59 templates/dcim/module.html:59 +msgid "Device Type" +msgstr "" + +#: dcim/forms/bulk_edit.py:492 dcim/forms/model_forms.py:336 +#: dcim/tables/modules.py:17 dcim/tables/modules.py:65 +#: templates/dcim/module.html:63 templates/dcim/modulebay.html:65 +#: templates/dcim/moduletype.html:11 +msgid "Module Type" +msgstr "" + +#: dcim/forms/bulk_edit.py:506 dcim/models/devices.py:472 +msgid "VM role" +msgstr "" + +#: dcim/forms/bulk_edit.py:509 dcim/forms/bulk_edit.py:533 +#: dcim/forms/bulk_edit.py:613 dcim/forms/bulk_import.py:368 +#: dcim/forms/bulk_import.py:372 dcim/forms/bulk_import.py:394 +#: dcim/forms/bulk_import.py:398 dcim/forms/bulk_import.py:523 +#: dcim/forms/bulk_import.py:527 dcim/forms/filtersets.py:615 +#: dcim/forms/filtersets.py:630 dcim/forms/filtersets.py:743 +#: dcim/forms/model_forms.py:349 dcim/forms/model_forms.py:375 +#: dcim/forms/model_forms.py:476 virtualization/forms/bulk_import.py:131 +#: virtualization/forms/bulk_import.py:132 +#: virtualization/forms/filtersets.py:177 +#: virtualization/forms/model_forms.py:216 +msgid "Config template" +msgstr "" + +#: dcim/forms/bulk_edit.py:557 dcim/forms/bulk_edit.py:951 +#: dcim/forms/bulk_import.py:429 dcim/forms/filtersets.py:110 +#: dcim/forms/model_forms.py:435 dcim/forms/model_forms.py:775 +#: dcim/forms/model_forms.py:789 extras/filtersets.py:421 +msgid "Device type" +msgstr "" + +#: dcim/forms/bulk_edit.py:565 dcim/forms/bulk_import.py:410 +#: dcim/forms/filtersets.py:115 dcim/forms/model_forms.py:440 +msgid "Device role" +msgstr "" + +#: dcim/forms/bulk_edit.py:588 dcim/forms/bulk_import.py:435 +#: dcim/forms/filtersets.py:716 dcim/forms/model_forms.py:385 +#: dcim/forms/model_forms.py:444 extras/filtersets.py:437 +#: templates/dcim/device.html:208 templates/dcim/platform.html:27 +#: templates/virtualization/virtualmachine.html:30 +#: virtualization/forms/bulk_edit.py:157 +#: virtualization/forms/bulk_import.py:121 +#: virtualization/forms/filtersets.py:161 +#: virtualization/forms/model_forms.py:205 +msgid "Platform" +msgstr "" + +#: dcim/forms/bulk_edit.py:621 dcim/forms/bulk_edit.py:1171 +#: dcim/forms/bulk_edit.py:1534 dcim/forms/bulk_edit.py:1580 +#: dcim/forms/bulk_import.py:578 dcim/forms/bulk_import.py:640 +#: dcim/forms/bulk_import.py:666 dcim/forms/bulk_import.py:692 +#: dcim/forms/bulk_import.py:712 dcim/forms/bulk_import.py:765 +#: dcim/forms/bulk_import.py:879 dcim/forms/bulk_import.py:927 +#: dcim/forms/bulk_import.py:944 dcim/forms/bulk_import.py:956 +#: dcim/forms/bulk_import.py:1004 dcim/forms/bulk_import.py:1350 +#: dcim/forms/connections.py:23 dcim/forms/filtersets.py:127 +#: dcim/forms/filtersets.py:824 dcim/forms/filtersets.py:957 +#: dcim/forms/filtersets.py:1146 dcim/forms/filtersets.py:1168 +#: dcim/forms/filtersets.py:1190 dcim/forms/filtersets.py:1207 +#: dcim/forms/filtersets.py:1227 dcim/forms/filtersets.py:1334 +#: dcim/forms/filtersets.py:1356 dcim/forms/filtersets.py:1377 +#: dcim/forms/filtersets.py:1392 dcim/forms/filtersets.py:1403 +#: dcim/forms/filtersets.py:1467 dcim/forms/filtersets.py:1491 +#: dcim/forms/filtersets.py:1515 dcim/forms/model_forms.py:554 +#: dcim/forms/model_forms.py:752 dcim/forms/model_forms.py:1003 +#: dcim/forms/model_forms.py:1452 dcim/forms/object_create.py:239 +#: dcim/tables/connections.py:22 dcim/tables/connections.py:41 +#: dcim/tables/connections.py:60 dcim/tables/devices.py:314 +#: dcim/tables/devices.py:374 dcim/tables/devices.py:418 +#: dcim/tables/devices.py:463 dcim/tables/devices.py:511 +#: dcim/tables/devices.py:597 dcim/tables/devices.py:693 +#: dcim/tables/devices.py:753 dcim/tables/devices.py:803 +#: dcim/tables/devices.py:863 dcim/tables/devices.py:915 +#: dcim/tables/devices.py:1037 dcim/tables/modules.py:52 +#: extras/forms/filtersets.py:304 ipam/forms/bulk_import.py:306 +#: ipam/forms/bulk_import.py:492 ipam/forms/bulk_import.py:543 +#: ipam/forms/filtersets.py:594 ipam/forms/model_forms.py:687 +#: ipam/tables/vlans.py:176 templates/dcim/consoleport.html:23 +#: templates/dcim/consoleserverport.html:23 templates/dcim/device.html:13 +#: templates/dcim/device.html:145 templates/dcim/device_edit.html:10 +#: templates/dcim/devicebay.html:23 templates/dcim/devicebay.html:55 +#: templates/dcim/frontport.html:23 templates/dcim/interface.html:31 +#: templates/dcim/interface.html:163 templates/dcim/inventoryitem.html:21 +#: templates/dcim/module.html:55 templates/dcim/modulebay.html:21 +#: templates/dcim/poweroutlet.html:23 templates/dcim/powerport.html:23 +#: templates/dcim/rearport.html:23 templates/dcim/virtualchassis.html:58 +#: templates/dcim/virtualchassis_edit.html:52 +#: templates/dcim/virtualdevicecontext.html:25 +#: templates/ipam/ipaddress_edit.html:42 +#: templates/ipam/l2vpntermination_edit.html:22 +#: templates/ipam/service_create.html:17 templates/ipam/service_edit.html:16 +#: templates/virtualization/virtualmachine.html:115 +#: templates/wireless/inc/wirelesslink_interface.html:6 +#: virtualization/filtersets.py:163 virtualization/forms/bulk_edit.py:134 +#: virtualization/forms/bulk_import.py:98 +#: virtualization/forms/filtersets.py:121 +#: virtualization/forms/model_forms.py:187 +#: virtualization/tables/virtualmachines.py:59 +#: wireless/forms/model_forms.py:100 wireless/forms/model_forms.py:140 +#: wireless/tables/wirelesslan.py:75 +msgid "Device" +msgstr "" + +#: dcim/forms/bulk_edit.py:624 netbox/navigation/menu.py:421 +#: templates/extras/dashboard/widget_config.html:7 +msgid "Configuration" +msgstr "" + +#: dcim/forms/bulk_edit.py:638 dcim/forms/bulk_import.py:590 +#: dcim/forms/model_forms.py:568 dcim/forms/model_forms.py:794 +msgid "Module type" +msgstr "" + +#: dcim/forms/bulk_edit.py:689 dcim/forms/bulk_edit.py:874 +#: dcim/forms/bulk_edit.py:893 dcim/forms/bulk_edit.py:916 +#: dcim/forms/bulk_edit.py:958 dcim/forms/bulk_edit.py:1002 +#: dcim/forms/bulk_edit.py:1053 dcim/forms/bulk_edit.py:1080 +#: dcim/forms/bulk_edit.py:1107 dcim/forms/bulk_edit.py:1125 +#: dcim/forms/bulk_edit.py:1143 dcim/forms/filtersets.py:63 +#: dcim/forms/object_create.py:45 templates/dcim/cable.html:33 +#: templates/dcim/consoleport.html:35 templates/dcim/consoleserverport.html:35 +#: templates/dcim/devicebay.html:31 templates/dcim/frontport.html:35 +#: templates/dcim/inc/panels/inventory_items.html:11 +#: templates/dcim/interface.html:43 templates/dcim/inventoryitem.html:33 +#: templates/dcim/modulebay.html:31 templates/dcim/poweroutlet.html:35 +#: templates/dcim/powerport.html:35 templates/dcim/rearport.html:35 +#: templates/extras/customfield.html:27 templates/generic/bulk_import.html:155 +msgid "Label" +msgstr "" + +#: dcim/forms/bulk_edit.py:698 dcim/forms/filtersets.py:974 +#: templates/dcim/cable.html:51 +msgid "Length" +msgstr "" + +#: dcim/forms/bulk_edit.py:703 dcim/forms/bulk_import.py:1158 +#: dcim/forms/bulk_import.py:1161 dcim/forms/filtersets.py:978 +msgid "Length unit" +msgstr "" + +#: dcim/forms/bulk_edit.py:727 templates/dcim/virtualchassis.html:24 +msgid "Domain" +msgstr "" + +#: dcim/forms/bulk_edit.py:795 dcim/forms/bulk_import.py:1273 +#: dcim/forms/filtersets.py:1063 dcim/forms/model_forms.py:656 +msgid "Power panel" +msgstr "" + +#: dcim/forms/bulk_edit.py:817 dcim/forms/bulk_import.py:1309 +#: dcim/forms/filtersets.py:1085 templates/dcim/powerfeed.html:90 +msgid "Supply" +msgstr "" + +#: dcim/forms/bulk_edit.py:823 dcim/forms/bulk_import.py:1314 +#: dcim/forms/filtersets.py:1090 templates/dcim/powerfeed.html:102 +msgid "Phase" +msgstr "" + +#: dcim/forms/bulk_edit.py:829 dcim/forms/filtersets.py:1095 +#: templates/dcim/powerfeed.html:94 +msgid "Voltage" +msgstr "" + +#: dcim/forms/bulk_edit.py:833 dcim/forms/filtersets.py:1099 +#: templates/dcim/powerfeed.html:98 +msgid "Amperage" +msgstr "" + +#: dcim/forms/bulk_edit.py:837 dcim/forms/filtersets.py:1103 +msgid "Max utilization" +msgstr "" + +#: dcim/forms/bulk_edit.py:841 dcim/forms/bulk_edit.py:1200 +#: dcim/forms/bulk_edit.py:1217 dcim/forms/bulk_edit.py:1234 +#: dcim/forms/bulk_edit.py:1252 dcim/forms/bulk_edit.py:1340 +#: dcim/forms/bulk_edit.py:1478 dcim/forms/bulk_edit.py:1495 +msgid "Mark connected" +msgstr "" + +#: dcim/forms/bulk_edit.py:926 +msgid "Maximum draw" +msgstr "" + +#: dcim/forms/bulk_edit.py:929 dcim/models/device_component_templates.py:257 +#: dcim/models/device_components.py:358 +msgid "Maximum power draw (watts)" +msgstr "" + +#: dcim/forms/bulk_edit.py:932 +msgid "Allocated draw" +msgstr "" + +#: dcim/forms/bulk_edit.py:935 dcim/models/device_component_templates.py:264 +#: dcim/models/device_components.py:365 +msgid "Allocated power draw (watts)" +msgstr "" + +#: dcim/forms/bulk_edit.py:968 dcim/forms/bulk_import.py:723 +#: dcim/forms/model_forms.py:847 dcim/forms/model_forms.py:1075 +#: dcim/forms/model_forms.py:1360 dcim/forms/object_import.py:60 +msgid "Power port" +msgstr "" + +#: dcim/forms/bulk_edit.py:973 +msgid "Feed leg" +msgstr "" + +#: dcim/forms/bulk_edit.py:1019 dcim/forms/bulk_edit.py:1325 +msgid "Management only" +msgstr "" + +#: dcim/forms/bulk_edit.py:1029 dcim/forms/bulk_edit.py:1331 +#: dcim/forms/bulk_import.py:813 dcim/forms/filtersets.py:1285 +#: dcim/forms/object_import.py:95 dcim/models/device_component_templates.py:412 +#: dcim/models/device_components.py:668 +msgid "PoE mode" +msgstr "" + +#: dcim/forms/bulk_edit.py:1035 dcim/forms/bulk_edit.py:1337 +#: dcim/forms/bulk_import.py:819 dcim/forms/filtersets.py:1290 +#: dcim/forms/object_import.py:100 +#: dcim/models/device_component_templates.py:418 +#: dcim/models/device_components.py:674 +msgid "PoE type" +msgstr "" + +#: dcim/forms/bulk_edit.py:1041 dcim/forms/filtersets.py:1295 +#: dcim/forms/object_import.py:105 +msgid "Wireless role" +msgstr "" + +#: dcim/forms/bulk_edit.py:1178 dcim/forms/model_forms.py:587 +#: dcim/forms/model_forms.py:1018 dcim/tables/devices.py:337 +#: templates/dcim/consoleport.html:27 templates/dcim/consoleserverport.html:27 +#: templates/dcim/frontport.html:27 templates/dcim/interface.html:35 +#: templates/dcim/module.html:51 templates/dcim/modulebay.html:57 +#: templates/dcim/poweroutlet.html:27 templates/dcim/powerport.html:27 +#: templates/dcim/rearport.html:27 +msgid "Module" +msgstr "" + +#: dcim/forms/bulk_edit.py:1305 dcim/tables/devices.py:663 +#: templates/dcim/interface.html:105 +msgid "LAG" +msgstr "" + +#: dcim/forms/bulk_edit.py:1310 dcim/forms/model_forms.py:1102 +msgid "Virtual device contexts" +msgstr "" + +#: dcim/forms/bulk_edit.py:1316 dcim/forms/bulk_import.py:651 +#: dcim/forms/bulk_import.py:677 dcim/forms/filtersets.py:1155 +#: dcim/forms/filtersets.py:1177 dcim/forms/filtersets.py:1249 +#: dcim/tables/devices.py:609 +#: templates/circuits/inc/circuit_termination.html:94 +#: templates/dcim/consoleport.html:43 templates/dcim/consoleserverport.html:43 +msgid "Speed" +msgstr "" + +#: dcim/forms/bulk_edit.py:1345 dcim/forms/bulk_import.py:822 +#: virtualization/forms/bulk_edit.py:230 +#: virtualization/forms/bulk_import.py:164 +msgid "Mode" +msgstr "" + +#: dcim/forms/bulk_edit.py:1353 dcim/forms/model_forms.py:1151 +#: ipam/forms/bulk_import.py:180 ipam/forms/filtersets.py:481 +#: ipam/models/vlans.py:82 virtualization/forms/bulk_edit.py:237 +#: virtualization/forms/model_forms.py:303 +msgid "VLAN group" +msgstr "" + +#: dcim/forms/bulk_edit.py:1361 dcim/forms/model_forms.py:1156 +#: dcim/tables/devices.py:582 virtualization/forms/bulk_edit.py:245 +#: virtualization/forms/model_forms.py:308 +msgid "Untagged VLAN" +msgstr "" + +#: dcim/forms/bulk_edit.py:1369 dcim/forms/model_forms.py:1165 +#: dcim/tables/devices.py:588 virtualization/forms/bulk_edit.py:253 +#: virtualization/forms/model_forms.py:317 +msgid "Tagged VLANs" +msgstr "" + +#: dcim/forms/bulk_edit.py:1379 dcim/forms/model_forms.py:1138 +msgid "Wireless LAN group" +msgstr "" + +#: dcim/forms/bulk_edit.py:1384 dcim/forms/model_forms.py:1143 +#: dcim/tables/devices.py:618 netbox/navigation/menu.py:134 +#: templates/dcim/interface.html:285 wireless/tables/wirelesslan.py:24 +msgid "Wireless LANs" +msgstr "" + +#: dcim/forms/bulk_edit.py:1393 dcim/forms/filtersets.py:1223 +#: dcim/forms/model_forms.py:1184 ipam/forms/bulk_edit.py:272 +#: ipam/forms/bulk_edit.py:363 ipam/forms/filtersets.py:170 +#: templates/dcim/interface.html:122 templates/ipam/prefix.html:96 +#: virtualization/forms/model_forms.py:331 +msgid "Addressing" +msgstr "" + +#: dcim/forms/bulk_edit.py:1394 dcim/forms/filtersets.py:645 +#: dcim/forms/model_forms.py:1185 virtualization/forms/model_forms.py:332 +msgid "Operation" +msgstr "" + +#: dcim/forms/bulk_edit.py:1395 dcim/forms/filtersets.py:1224 +#: dcim/forms/model_forms.py:879 dcim/forms/model_forms.py:1187 +msgid "PoE" +msgstr "" + +#: dcim/forms/bulk_edit.py:1396 dcim/forms/model_forms.py:1186 +#: templates/dcim/interface.html:93 virtualization/forms/bulk_edit.py:264 +#: virtualization/forms/model_forms.py:333 +msgid "Related Interfaces" +msgstr "" + +#: dcim/forms/bulk_edit.py:1397 dcim/forms/model_forms.py:1188 +#: virtualization/forms/bulk_edit.py:265 +#: virtualization/forms/model_forms.py:334 +msgid "802.1Q Switching" +msgstr "" + +#: dcim/forms/bulk_edit.py:1458 dcim/forms/bulk_edit.py:1460 +msgid "Interface mode must be specified to assign VLANs" +msgstr "" + +#: dcim/forms/bulk_edit.py:1465 dcim/forms/common.py:50 +msgid "An access interface cannot have tagged VLANs assigned." +msgstr "" + +#: dcim/forms/bulk_import.py:63 +msgid "Name of parent region" +msgstr "" + +#: dcim/forms/bulk_import.py:77 +msgid "Name of parent site group" +msgstr "" + +#: dcim/forms/bulk_import.py:96 +msgid "Assigned region" +msgstr "" + +#: dcim/forms/bulk_import.py:103 tenancy/forms/bulk_import.py:44 +#: tenancy/forms/bulk_import.py:85 wireless/forms/bulk_import.py:40 +msgid "Assigned group" +msgstr "" + +#: dcim/forms/bulk_import.py:122 +msgid "available options" +msgstr "" + +#: dcim/forms/bulk_import.py:133 dcim/forms/bulk_import.py:480 +#: dcim/forms/bulk_import.py:1270 ipam/forms/bulk_import.py:177 +#: ipam/forms/bulk_import.py:444 virtualization/forms/bulk_import.py:62 +#: virtualization/forms/bulk_import.py:88 +msgid "Assigned site" +msgstr "" + +#: dcim/forms/bulk_import.py:140 +msgid "Parent location" +msgstr "" + +#: dcim/forms/bulk_import.py:142 +msgid "Location not found." +msgstr "" + +#: dcim/forms/bulk_import.py:191 +msgid "Name of assigned tenant" +msgstr "" + +#: dcim/forms/bulk_import.py:203 +msgid "Name of assigned role" +msgstr "" + +#: dcim/forms/bulk_import.py:209 +msgid "Rack type" +msgstr "" + +#: dcim/forms/bulk_import.py:214 +msgid "Rail-to-rail width (in inches)" +msgstr "" + +#: dcim/forms/bulk_import.py:220 +msgid "Unit for outer dimensions" +msgstr "" + +#: dcim/forms/bulk_import.py:226 +msgid "Unit for rack weights" +msgstr "" + +#: dcim/forms/bulk_import.py:252 +msgid "Parent site" +msgstr "" + +#: dcim/forms/bulk_import.py:259 dcim/forms/bulk_import.py:1283 +msgid "Rack's location (if any)" +msgstr "" + +#: dcim/forms/bulk_import.py:268 dcim/forms/model_forms.py:246 +#: dcim/tables/racks.py:153 templates/dcim/rackreservation.html:11 +#: templates/dcim/rackreservation.html:52 +msgid "Units" +msgstr "" + +#: dcim/forms/bulk_import.py:271 +msgid "Comma-separated list of individual unit numbers" +msgstr "" + +#: dcim/forms/bulk_import.py:314 +msgid "The manufacturer which produces this device type" +msgstr "" + +#: dcim/forms/bulk_import.py:321 +msgid "The default platform for devices of this type (optional)" +msgstr "" + +#: dcim/forms/bulk_import.py:326 +msgid "Device weight" +msgstr "" + +#: dcim/forms/bulk_import.py:332 +msgid "Unit for device weight" +msgstr "" + +#: dcim/forms/bulk_import.py:352 +msgid "Module weight" +msgstr "" + +#: dcim/forms/bulk_import.py:358 +msgid "Unit for module weight" +msgstr "" + +#: dcim/forms/bulk_import.py:391 +msgid "Limit platform assignments to this manufacturer" +msgstr "" + +#: dcim/forms/bulk_import.py:413 tenancy/forms/bulk_import.py:106 +msgid "Assigned role" +msgstr "" + +#: dcim/forms/bulk_import.py:426 +msgid "Device type manufacturer" +msgstr "" + +#: dcim/forms/bulk_import.py:432 +msgid "Device type model" +msgstr "" + +#: dcim/forms/bulk_import.py:439 virtualization/forms/bulk_import.py:125 +msgid "Assigned platform" +msgstr "" + +#: dcim/forms/bulk_import.py:447 dcim/forms/bulk_import.py:451 +#: dcim/forms/model_forms.py:460 +msgid "Virtual chassis" +msgstr "" + +#: dcim/forms/bulk_import.py:454 dcim/forms/model_forms.py:449 +#: dcim/tables/devices.py:231 extras/filtersets.py:470 +#: extras/forms/filtersets.py:305 ipam/forms/bulk_edit.py:480 +#: ipam/forms/model_forms.py:590 templates/dcim/device.html:256 +#: templates/virtualization/cluster.html:11 +#: templates/virtualization/virtualmachine.html:92 +#: templates/virtualization/virtualmachine.html:102 +#: virtualization/filtersets.py:153 virtualization/filtersets.py:268 +#: virtualization/forms/bulk_edit.py:126 virtualization/forms/bulk_import.py:91 +#: virtualization/forms/filtersets.py:95 virtualization/forms/filtersets.py:116 +#: virtualization/forms/filtersets.py:192 +#: virtualization/forms/model_forms.py:81 +#: virtualization/forms/model_forms.py:178 +#: virtualization/tables/virtualmachines.py:55 +msgid "Cluster" +msgstr "" + +#: dcim/forms/bulk_import.py:458 +msgid "Virtualization cluster" +msgstr "" + +#: dcim/forms/bulk_import.py:487 +msgid "Assigned location (if any)" +msgstr "" + +#: dcim/forms/bulk_import.py:494 +msgid "Assigned rack (if any)" +msgstr "" + +#: dcim/forms/bulk_import.py:497 +msgid "Face" +msgstr "" + +#: dcim/forms/bulk_import.py:500 +msgid "Mounted rack face" +msgstr "" + +#: dcim/forms/bulk_import.py:507 +msgid "Parent device (for child devices)" +msgstr "" + +#: dcim/forms/bulk_import.py:510 +msgid "Device bay" +msgstr "" + +#: dcim/forms/bulk_import.py:514 +msgid "Device bay in which this device is installed (for child devices)" +msgstr "" + +#: dcim/forms/bulk_import.py:520 +msgid "Airflow direction" +msgstr "" + +#: dcim/forms/bulk_import.py:581 +msgid "The device in which this module is installed" +msgstr "" + +#: dcim/forms/bulk_import.py:584 dcim/forms/model_forms.py:561 +msgid "Module bay" +msgstr "" + +#: dcim/forms/bulk_import.py:587 +msgid "The module bay in which this module is installed" +msgstr "" + +#: dcim/forms/bulk_import.py:593 +msgid "The type of module" +msgstr "" + +#: dcim/forms/bulk_import.py:601 dcim/forms/model_forms.py:574 +msgid "Replicate components" +msgstr "" + +#: dcim/forms/bulk_import.py:603 +msgid "" +"Automatically populate components associated with this module type (enabled " +"by default)" +msgstr "" + +#: dcim/forms/bulk_import.py:606 dcim/forms/model_forms.py:580 +msgid "Adopt components" +msgstr "" + +#: dcim/forms/bulk_import.py:608 dcim/forms/model_forms.py:583 +msgid "Adopt already existing components" +msgstr "" + +#: dcim/forms/bulk_import.py:648 dcim/forms/bulk_import.py:674 +#: dcim/forms/bulk_import.py:700 +msgid "Port type" +msgstr "" + +#: dcim/forms/bulk_import.py:656 dcim/forms/bulk_import.py:682 +msgid "Port speed in bps" +msgstr "" + +#: dcim/forms/bulk_import.py:720 +msgid "Outlet type" +msgstr "" + +#: dcim/forms/bulk_import.py:727 +msgid "Local power port which feeds this outlet" +msgstr "" + +#: dcim/forms/bulk_import.py:730 +msgid "Feed lag" +msgstr "" + +#: dcim/forms/bulk_import.py:733 +msgid "Electrical phase (for three-phase circuits)" +msgstr "" + +#: dcim/forms/bulk_import.py:774 dcim/forms/model_forms.py:1113 +#: virtualization/forms/bulk_import.py:154 +#: virtualization/forms/model_forms.py:287 +msgid "Parent interface" +msgstr "" + +#: dcim/forms/bulk_import.py:781 dcim/forms/model_forms.py:1121 +#: virtualization/forms/bulk_import.py:161 +#: virtualization/forms/model_forms.py:295 +msgid "Bridged interface" +msgstr "" + +#: dcim/forms/bulk_import.py:784 +msgid "Lag" +msgstr "" + +#: dcim/forms/bulk_import.py:788 +msgid "Parent LAG interface" +msgstr "" + +#: dcim/forms/bulk_import.py:791 +msgid "Vdcs" +msgstr "" + +#: dcim/forms/bulk_import.py:796 +msgid "VDC names separated by commas, encased with double quotes. Example:" +msgstr "" + +#: dcim/forms/bulk_import.py:802 +msgid "Physical medium" +msgstr "" + +#: dcim/forms/bulk_import.py:805 dcim/forms/filtersets.py:1256 +msgid "Duplex" +msgstr "" + +#: dcim/forms/bulk_import.py:810 +msgid "Poe mode" +msgstr "" + +#: dcim/forms/bulk_import.py:816 +msgid "Poe type" +msgstr "" + +#: dcim/forms/bulk_import.py:825 virtualization/forms/bulk_import.py:167 +msgid "IEEE 802.1Q operational mode (for L2 interfaces)" +msgstr "" + +#: dcim/forms/bulk_import.py:832 ipam/forms/bulk_import.py:163 +#: ipam/forms/bulk_import.py:249 ipam/forms/bulk_import.py:285 +#: ipam/forms/filtersets.py:200 ipam/forms/filtersets.py:270 +#: ipam/forms/filtersets.py:325 virtualization/forms/bulk_import.py:174 +msgid "Assigned VRF" +msgstr "" + +#: dcim/forms/bulk_import.py:835 +msgid "Rf role" +msgstr "" + +#: dcim/forms/bulk_import.py:838 +msgid "Wireless role (AP/station)" +msgstr "" + +#: dcim/forms/bulk_import.py:884 dcim/forms/model_forms.py:892 +#: dcim/forms/model_forms.py:1368 dcim/forms/object_import.py:122 +msgid "Rear port" +msgstr "" + +#: dcim/forms/bulk_import.py:887 +msgid "Corresponding rear port" +msgstr "" + +#: dcim/forms/bulk_import.py:892 dcim/forms/bulk_import.py:933 +#: dcim/forms/bulk_import.py:1148 +msgid "Physical medium classification" +msgstr "" + +#: dcim/forms/bulk_import.py:961 dcim/tables/devices.py:824 +msgid "Installed device" +msgstr "" + +#: dcim/forms/bulk_import.py:965 +msgid "Child device installed within this bay" +msgstr "" + +#: dcim/forms/bulk_import.py:967 +msgid "Child device not found." +msgstr "" + +#: dcim/forms/bulk_import.py:1025 +msgid "Parent inventory item" +msgstr "" + +#: dcim/forms/bulk_import.py:1028 +msgid "Component type" +msgstr "" + +#: dcim/forms/bulk_import.py:1032 +msgid "Component Type" +msgstr "" + +#: dcim/forms/bulk_import.py:1035 +msgid "Compnent name" +msgstr "" + +#: dcim/forms/bulk_import.py:1037 +msgid "Component Name" +msgstr "" + +#: dcim/forms/bulk_import.py:1103 +msgid "Side A device" +msgstr "" + +#: dcim/forms/bulk_import.py:1106 dcim/forms/bulk_import.py:1124 +msgid "Device name" +msgstr "" + +#: dcim/forms/bulk_import.py:1109 +msgid "Side A type" +msgstr "" + +#: dcim/forms/bulk_import.py:1112 dcim/forms/bulk_import.py:1130 +msgid "Termination type" +msgstr "" + +#: dcim/forms/bulk_import.py:1115 +msgid "Side A name" +msgstr "" + +#: dcim/forms/bulk_import.py:1116 dcim/forms/bulk_import.py:1134 +msgid "Termination name" +msgstr "" + +#: dcim/forms/bulk_import.py:1121 +msgid "Side B device" +msgstr "" + +#: dcim/forms/bulk_import.py:1127 +msgid "Side B type" +msgstr "" + +#: dcim/forms/bulk_import.py:1133 +msgid "Side B name" +msgstr "" + +#: dcim/forms/bulk_import.py:1142 wireless/forms/bulk_import.py:86 +msgid "Connection status" +msgstr "" + +#: dcim/forms/bulk_import.py:1221 dcim/forms/model_forms.py:688 +#: dcim/tables/devices.py:1007 templates/dcim/device.html:147 +#: templates/dcim/virtualchassis.html:28 templates/dcim/virtualchassis.html:60 +msgid "Master" +msgstr "" + +#: dcim/forms/bulk_import.py:1225 +msgid "Master device" +msgstr "" + +#: dcim/forms/bulk_import.py:1242 +msgid "Name of parent site" +msgstr "" + +#: dcim/forms/bulk_import.py:1276 +msgid "Upstream power panel" +msgstr "" + +#: dcim/forms/bulk_import.py:1306 +msgid "Primary or redundant" +msgstr "" + +#: dcim/forms/bulk_import.py:1311 +msgid "Supply type (AC/DC)" +msgstr "" + +#: dcim/forms/bulk_import.py:1316 +msgid "Single or three-phase" +msgstr "" + +#: dcim/forms/common.py:24 dcim/models/device_components.py:529 +#: templates/dcim/interface.html:58 +#: templates/virtualization/vminterface.html:58 +#: virtualization/forms/bulk_edit.py:222 +msgid "MTU" +msgstr "" + +#: dcim/forms/common.py:65 +#, python-brace-format +msgid "" +"The tagged VLANs ({vlans}) must belong to the same site as the interface's " +"parent device/VM, or they must be global" +msgstr "" + +#: dcim/forms/common.py:110 +msgid "" +"Cannot install module with placeholder values in a module bay with no " +"position defined." +msgstr "" + +#: dcim/forms/common.py:119 +#, python-brace-format +msgid "Cannot adopt {model} {name} as it already belongs to a module" +msgstr "" + +#: dcim/forms/common.py:128 +#, python-brace-format +msgid "A {model} named {name} already exists" +msgstr "" + +#: dcim/forms/connections.py:45 dcim/tables/power.py:66 +#: templates/dcim/inc/cable_termination.html:37 +#: templates/dcim/powerfeed.html:27 templates/dcim/powerpanel.html:19 +#: templates/dcim/trace/powerpanel.html:4 +msgid "Power Panel" +msgstr "" + +#: dcim/forms/connections.py:54 dcim/forms/model_forms.py:669 +#: templates/dcim/powerfeed.html:22 templates/dcim/powerport.html:84 +msgid "Power Feed" +msgstr "" + +#: dcim/forms/connections.py:74 +msgid "Side" +msgstr "" + +#: dcim/forms/filtersets.py:140 +msgid "Parent region" +msgstr "" + +#: dcim/forms/filtersets.py:154 tenancy/forms/bulk_import.py:28 +#: tenancy/forms/bulk_import.py:62 tenancy/forms/filtersets.py:33 +#: tenancy/forms/filtersets.py:62 wireless/forms/bulk_import.py:25 +#: wireless/forms/filtersets.py:24 +msgid "Parent group" +msgstr "" + +#: dcim/forms/filtersets.py:244 dcim/forms/filtersets.py:328 +msgid "Function" +msgstr "" + +#: dcim/forms/filtersets.py:415 dcim/forms/model_forms.py:308 +#: templates/inc/panels/image_attachments.html:5 +msgid "Images" +msgstr "" + +#: dcim/forms/filtersets.py:416 dcim/forms/filtersets.py:540 +#: dcim/forms/filtersets.py:649 +msgid "Components" +msgstr "" + +#: dcim/forms/filtersets.py:437 +msgid "Subdevice role" +msgstr "" + +#: dcim/forms/filtersets.py:652 extras/forms/model_forms.py:496 +#: templates/extras/configrevision.html:171 users/forms/model_forms.py:63 +msgid "Miscellaneous" +msgstr "" + +#: dcim/forms/filtersets.py:710 +msgid "Model" +msgstr "" + +#: dcim/forms/filtersets.py:761 +msgid "Virtual chassis member" +msgstr "" + +#: dcim/forms/filtersets.py:1115 +msgid "Cabled" +msgstr "" + +#: dcim/forms/filtersets.py:1122 +msgid "Occupied" +msgstr "" + +#: dcim/forms/filtersets.py:1147 dcim/forms/filtersets.py:1169 +#: dcim/forms/filtersets.py:1191 dcim/forms/filtersets.py:1208 +#: dcim/forms/filtersets.py:1228 dcim/tables/devices.py:367 +#: templates/dcim/consoleport.html:59 templates/dcim/consoleserverport.html:59 +#: templates/dcim/frontport.html:74 templates/dcim/interface.html:142 +#: templates/dcim/powerfeed.html:118 templates/dcim/poweroutlet.html:63 +#: templates/dcim/powerport.html:63 templates/dcim/rearport.html:70 +msgid "Connection" +msgstr "" + +#: dcim/forms/filtersets.py:1236 dcim/forms/model_forms.py:1476 +#: templates/dcim/virtualdevicecontext.html:16 +msgid "Virtual Device Context" +msgstr "" + +#: dcim/forms/filtersets.py:1239 extras/forms/bulk_edit.py:294 +#: extras/forms/bulk_import.py:177 extras/forms/filtersets.py:454 +#: extras/forms/model_forms.py:445 extras/tables/tables.py:482 +#: templates/extras/journalentry.html:33 +msgid "Kind" +msgstr "" + +#: dcim/forms/filtersets.py:1268 +msgid "Mgmt only" +msgstr "" + +#: dcim/forms/filtersets.py:1280 dcim/forms/model_forms.py:1179 +#: dcim/models/device_components.py:627 templates/dcim/interface.html:130 +msgid "WWN" +msgstr "" + +#: dcim/forms/filtersets.py:1300 +msgid "Wireless channel" +msgstr "" + +#: dcim/forms/filtersets.py:1304 +msgid "Channel frequency (MHz)" +msgstr "" + +#: dcim/forms/filtersets.py:1308 +msgid "Channel width (MHz)" +msgstr "" + +#: dcim/forms/filtersets.py:1312 templates/dcim/interface.html:86 +msgid "Transmit power (dBm)" +msgstr "" + +#: dcim/forms/filtersets.py:1335 dcim/forms/filtersets.py:1357 +#: dcim/tables/devices.py:344 templates/dcim/cable.html:12 +#: templates/dcim/cable_edit.html:46 templates/dcim/cable_trace.html:43 +#: templates/dcim/frontport.html:84 +#: templates/dcim/inc/connection_endpoints.html:4 +#: templates/dcim/rearport.html:80 templates/dcim/trace/cable.html:7 +msgid "Cable" +msgstr "" + +#: dcim/forms/filtersets.py:1425 dcim/tables/devices.py:934 +msgid "Discovered" +msgstr "" + +#: dcim/forms/formsets.py:20 +#, python-brace-format +msgid "A virtual chassis member already exists in position {vc_position}." +msgstr "" + +#: dcim/forms/model_forms.py:101 dcim/tables/devices.py:183 +#: templates/dcim/sitegroup.html:26 +msgid "Site Group" +msgstr "" + +#: dcim/forms/model_forms.py:142 +msgid "Contact Info" +msgstr "" + +#: dcim/forms/model_forms.py:197 templates/dcim/rackrole.html:20 +msgid "Rack Role" +msgstr "" + +#: dcim/forms/model_forms.py:248 +msgid "" +"Comma-separated list of numeric unit IDs. A range may be specified using a " +"hyphen." +msgstr "" + +#: dcim/forms/model_forms.py:259 dcim/tables/racks.py:133 +msgid "Reservation" +msgstr "" + +#: dcim/forms/model_forms.py:297 dcim/forms/model_forms.py:380 +#: utilities/forms/fields/fields.py:47 +msgid "Slug" +msgstr "" + +#: dcim/forms/model_forms.py:304 templates/dcim/devicetype.html:12 +msgid "Chassis" +msgstr "" + +#: dcim/forms/model_forms.py:356 templates/dcim/devicerole.html:24 +msgid "Device Role" +msgstr "" + +#: dcim/forms/model_forms.py:424 dcim/models/devices.py:632 +msgid "The lowest-numbered unit occupied by the device" +msgstr "" + +#: dcim/forms/model_forms.py:468 +msgid "The position in the virtual chassis this device is identified by" +msgstr "" + +#: dcim/forms/model_forms.py:472 templates/dcim/device.html:148 +#: templates/dcim/virtualchassis.html:61 +#: templates/dcim/virtualchassis_edit.html:57 +#: templates/ipam/inc/panels/fhrp_groups.html:13 tenancy/forms/bulk_edit.py:146 +#: tenancy/forms/filtersets.py:111 +msgid "Priority" +msgstr "" + +#: dcim/forms/model_forms.py:473 +msgid "The priority of the device in the virtual chassis" +msgstr "" + +#: dcim/forms/model_forms.py:577 +msgid "Automatically populate components associated with this module type" +msgstr "" + +#: dcim/forms/model_forms.py:622 +msgid "Maximum length is 32767 (any unit)" +msgstr "" + +#: dcim/forms/model_forms.py:670 +msgid "Characteristics" +msgstr "" + +#: dcim/forms/model_forms.py:1129 +msgid "LAG interface" +msgstr "" + +#: dcim/forms/model_forms.py:1183 dcim/forms/model_forms.py:1344 +#: dcim/tables/connections.py:65 ipam/forms/bulk_import.py:320 +#: ipam/forms/bulk_import.py:557 ipam/forms/model_forms.py:272 +#: ipam/forms/model_forms.py:281 ipam/forms/model_forms.py:807 +#: ipam/forms/model_forms.py:816 ipam/tables/fhrp.py:64 ipam/tables/ip.py:368 +#: ipam/tables/vlans.py:165 templates/circuits/inc/circuit_termination.html:78 +#: templates/dcim/frontport.html:113 templates/dcim/interface.html:27 +#: templates/dcim/interface.html:186 templates/dcim/interface.html:318 +#: templates/dcim/inventoryitem_edit.html:54 templates/dcim/rearport.html:109 +#: templates/ipam/fhrpgroupassignment_edit.html:11 +#: templates/virtualization/vminterface.html:19 +#: templates/wireless/inc/wirelesslink_interface.html:10 +#: templates/wireless/wirelesslink.html:10 +#: templates/wireless/wirelesslink.html:49 +#: virtualization/forms/model_forms.py:330 wireless/forms/model_forms.py:112 +#: wireless/forms/model_forms.py:152 +msgid "Interface" +msgstr "" + +#: dcim/forms/model_forms.py:1277 +msgid "Child Device" +msgstr "" + +#: dcim/forms/model_forms.py:1278 +msgid "" +"Child devices must first be created and assigned to the site and rack of the " +"parent device." +msgstr "" + +#: dcim/forms/model_forms.py:1320 +msgid "Console port" +msgstr "" + +#: dcim/forms/model_forms.py:1328 +msgid "Console server port" +msgstr "" + +#: dcim/forms/model_forms.py:1336 +msgid "Front port" +msgstr "" + +#: dcim/forms/model_forms.py:1352 +msgid "Power outlet" +msgstr "" + +#: dcim/forms/model_forms.py:1372 templates/dcim/inventoryitem.html:17 +#: templates/dcim/inventoryitem_edit.html:10 +msgid "Inventory Item" +msgstr "" + +#: dcim/forms/model_forms.py:1424 +msgid "An InventoryItem can only be assigned to a single component." +msgstr "" + +#: dcim/forms/model_forms.py:1438 templates/dcim/inventoryitemrole.html:15 +msgid "Inventory Item Role" +msgstr "" + +#: dcim/forms/model_forms.py:1458 templates/dcim/device.html:212 +#: templates/dcim/virtualdevicecontext.html:33 +#: templates/virtualization/virtualmachine.html:51 +msgid "Primary IPv4" +msgstr "" + +#: dcim/forms/model_forms.py:1467 templates/dcim/device.html:228 +#: templates/dcim/virtualdevicecontext.html:44 +#: templates/virtualization/virtualmachine.html:67 +msgid "Primary IPv6" +msgstr "" + +#: dcim/forms/object_create.py:47 dcim/forms/object_create.py:181 +#: dcim/forms/object_create.py:321 +msgid "" +"Alphanumeric ranges are supported. (Must match the number of objects being " +"created.)" +msgstr "" + +#: dcim/forms/object_create.py:67 +#, python-brace-format +msgid "" +"The provided pattern specifies {value_count} values, but {pattern_count} are " +"expected." +msgstr "" + +#: dcim/forms/object_create.py:109 dcim/forms/object_create.py:253 +#: dcim/tables/devices.py:281 +msgid "Rear ports" +msgstr "" + +#: dcim/forms/object_create.py:110 dcim/forms/object_create.py:254 +msgid "Select one rear port assignment for each front port being created." +msgstr "" + +#: dcim/forms/object_create.py:233 +#, python-brace-format +msgid "" +"The string {module} will be replaced with the position of the " +"assigned module, if any." +msgstr "" + +#: dcim/forms/object_create.py:375 dcim/tables/devices.py:1013 +#: ipam/tables/fhrp.py:31 templates/dcim/virtualchassis.html:54 +#: templates/dcim/virtualchassis_edit.html:48 templates/ipam/fhrpgroup.html:39 +msgid "Members" +msgstr "" + +#: dcim/forms/object_create.py:384 +msgid "Initial position" +msgstr "" + +#: dcim/forms/object_create.py:387 +msgid "" +"Position of the first member device. Increases by one for each additional " +"member." +msgstr "" + +#: dcim/forms/object_create.py:401 +msgid "A position must be specified for the first VC member." +msgstr "" + +#: dcim/models/cables.py:63 dcim/models/device_component_templates.py:56 +#: dcim/models/device_components.py:64 extras/models/customfields.py:102 +msgid "label" +msgstr "" + +#: dcim/models/cables.py:72 +msgid "length" +msgstr "" + +#: dcim/models/cables.py:79 +msgid "length unit" +msgstr "" + +#: dcim/models/cables.py:94 +msgid "cable" +msgstr "" + +#: dcim/models/cables.py:95 +msgid "cables" +msgstr "" + +#: dcim/models/cables.py:247 ipam/models/asns.py:37 +msgid "end" +msgstr "" + +#: dcim/models/cables.py:297 +msgid "cable termination" +msgstr "" + +#: dcim/models/cables.py:298 +msgid "cable terminations" +msgstr "" + +#: dcim/models/cables.py:421 extras/models/configs.py:50 +msgid "is active" +msgstr "" + +#: dcim/models/cables.py:425 +msgid "is complete" +msgstr "" + +#: dcim/models/cables.py:429 +msgid "is split" +msgstr "" + +#: dcim/models/cables.py:435 +msgid "cable path" +msgstr "" + +#: dcim/models/cables.py:436 +msgid "cable paths" +msgstr "" + +#: dcim/models/device_component_templates.py:47 +#, python-brace-format +msgid "" +"{module} is accepted as a substitution for the module bay position when " +"attached to a module type." +msgstr "" + +#: dcim/models/device_component_templates.py:59 +#: dcim/models/device_components.py:67 +msgid "Physical label" +msgstr "" + +#: dcim/models/device_component_templates.py:104 +msgid "Component templates cannot be moved to a different device type." +msgstr "" + +#: dcim/models/device_component_templates.py:155 +msgid "" +"A component template cannot be associated with both a device type and a " +"module type." +msgstr "" + +#: dcim/models/device_component_templates.py:159 +msgid "" +"A component template must be associated with either a device type or a " +"module type." +msgstr "" + +#: dcim/models/device_component_templates.py:187 +msgid "console port template" +msgstr "" + +#: dcim/models/device_component_templates.py:188 +msgid "console port templates" +msgstr "" + +#: dcim/models/device_component_templates.py:221 +msgid "console server port template" +msgstr "" + +#: dcim/models/device_component_templates.py:222 +msgid "console server port templates" +msgstr "" + +#: dcim/models/device_component_templates.py:253 +#: dcim/models/device_components.py:354 +msgid "maximum draw" +msgstr "" + +#: dcim/models/device_component_templates.py:260 +#: dcim/models/device_components.py:361 +msgid "allocated draw" +msgstr "" + +#: dcim/models/device_component_templates.py:270 +msgid "power port template" +msgstr "" + +#: dcim/models/device_component_templates.py:271 +msgid "power port templates" +msgstr "" + +#: dcim/models/device_component_templates.py:290 +#: dcim/models/device_components.py:384 +#, python-brace-format +msgid "Allocated draw cannot exceed the maximum draw ({maximum_draw}W)." +msgstr "" + +#: dcim/models/device_component_templates.py:322 +#: dcim/models/device_components.py:479 +msgid "feed leg" +msgstr "" + +#: dcim/models/device_component_templates.py:326 +#: dcim/models/device_components.py:483 +msgid "Phase (for three-phase feeds)" +msgstr "" + +#: dcim/models/device_component_templates.py:332 +msgid "power outlet template" +msgstr "" + +#: dcim/models/device_component_templates.py:333 +msgid "power outlet templates" +msgstr "" + +#: dcim/models/device_component_templates.py:342 +#, python-brace-format +msgid "Parent power port ({power_port}) must belong to the same device type" +msgstr "" + +#: dcim/models/device_component_templates.py:346 +#, python-brace-format +msgid "Parent power port ({power_port}) must belong to the same module type" +msgstr "" + +#: dcim/models/device_component_templates.py:398 +#: dcim/models/device_components.py:609 +msgid "management only" +msgstr "" + +#: dcim/models/device_component_templates.py:406 +#: dcim/models/device_components.py:552 +msgid "bridge interface" +msgstr "" + +#: dcim/models/device_component_templates.py:424 +#: dcim/models/device_components.py:634 +msgid "wireless role" +msgstr "" + +#: dcim/models/device_component_templates.py:430 +msgid "interface template" +msgstr "" + +#: dcim/models/device_component_templates.py:431 +msgid "interface templates" +msgstr "" + +#: dcim/models/device_component_templates.py:438 +#: dcim/models/device_components.py:796 +#: virtualization/models/virtualmachines.py:340 +msgid "An interface cannot be bridged to itself." +msgstr "" + +#: dcim/models/device_component_templates.py:441 +#, python-brace-format +msgid "Bridge interface ({bridge}) must belong to the same device type" +msgstr "" + +#: dcim/models/device_component_templates.py:445 +#, python-brace-format +msgid "Bridge interface ({bridge}) must belong to the same module type" +msgstr "" + +#: dcim/models/device_component_templates.py:501 +#: dcim/models/device_components.py:976 +msgid "rear port position" +msgstr "" + +#: dcim/models/device_component_templates.py:526 +msgid "front port template" +msgstr "" + +#: dcim/models/device_component_templates.py:527 +msgid "front port templates" +msgstr "" + +#: dcim/models/device_component_templates.py:537 +#, python-brace-format +msgid "Rear port ({name}) must belong to the same device type" +msgstr "" + +#: dcim/models/device_component_templates.py:543 +#, python-brace-format +msgid "" +"Invalid rear port position ({position}); rear port {name} has only {count} " +"positions" +msgstr "" + +#: dcim/models/device_component_templates.py:596 +#: dcim/models/device_components.py:1045 +msgid "positions" +msgstr "" + +#: dcim/models/device_component_templates.py:607 +msgid "rear port template" +msgstr "" + +#: dcim/models/device_component_templates.py:608 +msgid "rear port templates" +msgstr "" + +#: dcim/models/device_component_templates.py:637 +#: dcim/models/device_components.py:1086 +msgid "position" +msgstr "" + +#: dcim/models/device_component_templates.py:640 +#: dcim/models/device_components.py:1089 +msgid "Identifier to reference when renaming installed components" +msgstr "" + +#: dcim/models/device_component_templates.py:646 +msgid "module bay template" +msgstr "" + +#: dcim/models/device_component_templates.py:647 +msgid "module bay templates" +msgstr "" + +#: dcim/models/device_component_templates.py:674 +msgid "device bay template" +msgstr "" + +#: dcim/models/device_component_templates.py:675 +msgid "device bay templates" +msgstr "" + +#: dcim/models/device_component_templates.py:688 +#, python-brace-format +msgid "" +"Subdevice role of device type ({device_type}) must be set to \"parent\" to " +"allow device bays." +msgstr "" + +#: dcim/models/device_component_templates.py:743 +#: dcim/models/device_components.py:1215 +msgid "part ID" +msgstr "" + +#: dcim/models/device_component_templates.py:745 +#: dcim/models/device_components.py:1217 +msgid "Manufacturer-assigned part identifier" +msgstr "" + +#: dcim/models/device_component_templates.py:759 +msgid "inventory item template" +msgstr "" + +#: dcim/models/device_component_templates.py:760 +msgid "inventory item templates" +msgstr "" + +#: dcim/models/device_components.py:107 +msgid "Components cannot be moved to a different device." +msgstr "" + +#: dcim/models/device_components.py:146 +msgid "cable end" +msgstr "" + +#: dcim/models/device_components.py:152 +msgid "mark connected" +msgstr "" + +#: dcim/models/device_components.py:154 +msgid "Treat as if a cable is connected" +msgstr "" + +#: dcim/models/device_components.py:172 +msgid "Must specify cable end (A or B) when attaching a cable." +msgstr "" + +#: dcim/models/device_components.py:176 +msgid "Cable end must not be set without a cable." +msgstr "" + +#: dcim/models/device_components.py:180 +msgid "Cannot mark as connected with a cable attached." +msgstr "" + +#: dcim/models/device_components.py:204 +#, python-brace-format +msgid "{class_name} models must declare a parent_object property" +msgstr "" + +#: dcim/models/device_components.py:289 dcim/models/device_components.py:318 +#: dcim/models/device_components.py:351 dcim/models/device_components.py:469 +msgid "Physical port type" +msgstr "" + +#: dcim/models/device_components.py:292 dcim/models/device_components.py:321 +msgid "speed" +msgstr "" + +#: dcim/models/device_components.py:296 dcim/models/device_components.py:325 +msgid "Port speed in bits per second" +msgstr "" + +#: dcim/models/device_components.py:302 +msgid "console port" +msgstr "" + +#: dcim/models/device_components.py:303 +msgid "console ports" +msgstr "" + +#: dcim/models/device_components.py:331 +msgid "console server port" +msgstr "" + +#: dcim/models/device_components.py:332 +msgid "console server ports" +msgstr "" + +#: dcim/models/device_components.py:371 +msgid "power port" +msgstr "" + +#: dcim/models/device_components.py:372 +msgid "power ports" +msgstr "" + +#: dcim/models/device_components.py:489 +msgid "power outlet" +msgstr "" + +#: dcim/models/device_components.py:490 +msgid "power outlets" +msgstr "" + +#: dcim/models/device_components.py:501 +#, python-brace-format +msgid "Parent power port ({power_port}) must belong to the same device" +msgstr "" + +#: dcim/models/device_components.py:532 +msgid "mode" +msgstr "" + +#: dcim/models/device_components.py:536 +msgid "IEEE 802.1Q tagging strategy" +msgstr "" + +#: dcim/models/device_components.py:544 +msgid "parent interface" +msgstr "" + +#: dcim/models/device_components.py:600 +msgid "parent LAG" +msgstr "" + +#: dcim/models/device_components.py:610 +msgid "This interface is used only for out-of-band management" +msgstr "" + +#: dcim/models/device_components.py:615 +msgid "speed (Kbps)" +msgstr "" + +#: dcim/models/device_components.py:618 +msgid "duplex" +msgstr "" + +#: dcim/models/device_components.py:628 +msgid "64-bit World Wide Name" +msgstr "" + +#: dcim/models/device_components.py:640 +msgid "wireless channel" +msgstr "" + +#: dcim/models/device_components.py:647 +msgid "channel frequency (MHz)" +msgstr "" + +#: dcim/models/device_components.py:648 dcim/models/device_components.py:656 +msgid "Populated by selected channel (if set)" +msgstr "" + +#: dcim/models/device_components.py:662 +msgid "transmit power (dBm)" +msgstr "" + +#: dcim/models/device_components.py:687 wireless/models.py:116 +msgid "wireless LANs" +msgstr "" + +#: dcim/models/device_components.py:695 +#: virtualization/models/virtualmachines.py:266 +msgid "untagged VLAN" +msgstr "" + +#: dcim/models/device_components.py:701 +#: virtualization/models/virtualmachines.py:272 +msgid "tagged VLANs" +msgstr "" + +#: dcim/models/device_components.py:737 +#: virtualization/models/virtualmachines.py:309 +msgid "interface" +msgstr "" + +#: dcim/models/device_components.py:738 +#: virtualization/models/virtualmachines.py:310 +msgid "interfaces" +msgstr "" + +#: dcim/models/device_components.py:749 +#, python-brace-format +msgid "{display_type} interfaces cannot have a cable attached." +msgstr "" + +#: dcim/models/device_components.py:757 +#, python-brace-format +msgid "{display_type} interfaces cannot be marked as connected." +msgstr "" + +#: dcim/models/device_components.py:766 +#: virtualization/models/virtualmachines.py:325 +msgid "An interface cannot be its own parent." +msgstr "" + +#: dcim/models/device_components.py:770 +msgid "Only virtual interfaces may be assigned to a parent interface." +msgstr "" + +#: dcim/models/device_components.py:777 +#, python-brace-format +msgid "" +"The selected parent interface ({interface}) belongs to a different device " +"({device})" +msgstr "" + +#: dcim/models/device_components.py:783 +#, python-brace-format +msgid "" +"The selected parent interface ({interface}) belongs to {device}, which is " +"not part of virtual chassis {virtual_chassis}." +msgstr "" + +#: dcim/models/device_components.py:803 +#, python-brace-format +msgid "" +"The selected bridge interface ({bridge}) belongs to a different device " +"({device})." +msgstr "" + +#: dcim/models/device_components.py:809 +#, python-brace-format +msgid "" +"The selected bridge interface ({interface}) belongs to {device}, which is " +"not part of virtual chassis {virtual_chassis}." +msgstr "" + +#: dcim/models/device_components.py:820 +msgid "Virtual interfaces cannot have a parent LAG interface." +msgstr "" + +#: dcim/models/device_components.py:824 +msgid "A LAG interface cannot be its own parent." +msgstr "" + +#: dcim/models/device_components.py:831 +#, python-brace-format +msgid "" +"The selected LAG interface ({lag}) belongs to a different device ({device})." +msgstr "" + +#: dcim/models/device_components.py:837 +#, python-brace-format +msgid "" +"The selected LAG interface ({lag}) belongs to {device}, which is not part of " +"virtual chassis {virtual_chassis}." +msgstr "" + +#: dcim/models/device_components.py:848 +msgid "Virtual interfaces cannot have a PoE mode." +msgstr "" + +#: dcim/models/device_components.py:852 +msgid "Virtual interfaces cannot have a PoE type." +msgstr "" + +#: dcim/models/device_components.py:858 +msgid "Must specify PoE mode when designating a PoE type." +msgstr "" + +#: dcim/models/device_components.py:865 +msgid "Wireless role may be set only on wireless interfaces." +msgstr "" + +#: dcim/models/device_components.py:867 +msgid "Channel may be set only on wireless interfaces." +msgstr "" + +#: dcim/models/device_components.py:873 +msgid "Channel frequency may be set only on wireless interfaces." +msgstr "" + +#: dcim/models/device_components.py:877 +msgid "Cannot specify custom frequency with channel selected." +msgstr "" + +#: dcim/models/device_components.py:883 +msgid "Channel width may be set only on wireless interfaces." +msgstr "" + +#: dcim/models/device_components.py:885 +msgid "Cannot specify custom width with channel selected." +msgstr "" + +#: dcim/models/device_components.py:893 +#, python-brace-format +msgid "" +"The untagged VLAN ({untagged_vlan}) must belong to the same site as the " +"interface's parent device, or it must be global." +msgstr "" + +#: dcim/models/device_components.py:982 +msgid "Mapped position on corresponding rear port" +msgstr "" + +#: dcim/models/device_components.py:998 +msgid "front port" +msgstr "" + +#: dcim/models/device_components.py:999 +msgid "front ports" +msgstr "" + +#: dcim/models/device_components.py:1013 +#, python-brace-format +msgid "Rear port ({rear_port}) must belong to the same device" +msgstr "" + +#: dcim/models/device_components.py:1021 +#, python-brace-format +msgid "" +"Invalid rear port position ({rear_port_position}): Rear port {name} has only " +"{positions} positions." +msgstr "" + +#: dcim/models/device_components.py:1051 +msgid "Number of front ports which may be mapped" +msgstr "" + +#: dcim/models/device_components.py:1056 +msgid "rear port" +msgstr "" + +#: dcim/models/device_components.py:1057 +msgid "rear ports" +msgstr "" + +#: dcim/models/device_components.py:1071 +#, python-brace-format +msgid "" +"The number of positions cannot be less than the number of mapped front ports " +"({frontport_count})" +msgstr "" + +#: dcim/models/device_components.py:1095 +msgid "module bay" +msgstr "" + +#: dcim/models/device_components.py:1096 +msgid "module bays" +msgstr "" + +#: dcim/models/device_components.py:1109 +msgid "parent_bay" +msgstr "" + +#: dcim/models/device_components.py:1117 +msgid "device bay" +msgstr "" + +#: dcim/models/device_components.py:1118 +msgid "device bays" +msgstr "" + +#: dcim/models/device_components.py:1128 +#, python-brace-format +msgid "This type of device ({device_type}) does not support device bays." +msgstr "" + +#: dcim/models/device_components.py:1134 +msgid "Cannot install a device into itself." +msgstr "" + +#: dcim/models/device_components.py:1142 +#, python-brace-format +msgid "" +"Cannot install the specified device; device is already installed in {bay}." +msgstr "" + +#: dcim/models/device_components.py:1163 +msgid "inventory item role" +msgstr "" + +#: dcim/models/device_components.py:1164 +msgid "inventory item roles" +msgstr "" + +#: dcim/models/device_components.py:1221 dcim/models/devices.py:595 +#: dcim/models/devices.py:1168 dcim/models/racks.py:113 +msgid "serial number" +msgstr "" + +#: dcim/models/device_components.py:1229 dcim/models/devices.py:603 +#: dcim/models/devices.py:1175 dcim/models/racks.py:120 +msgid "asset tag" +msgstr "" + +#: dcim/models/device_components.py:1230 +msgid "A unique tag used to identify this item" +msgstr "" + +#: dcim/models/device_components.py:1233 +msgid "discovered" +msgstr "" + +#: dcim/models/device_components.py:1235 +msgid "This item was automatically discovered" +msgstr "" + +#: dcim/models/device_components.py:1250 +msgid "inventory item" +msgstr "" + +#: dcim/models/device_components.py:1251 +msgid "inventory items" +msgstr "" + +#: dcim/models/device_components.py:1262 +msgid "Cannot assign self as parent." +msgstr "" + +#: dcim/models/device_components.py:1270 +msgid "Parent inventory item does not belong to the same device." +msgstr "" + +#: dcim/models/device_components.py:1276 +msgid "Cannot move an inventory item with dependent children" +msgstr "" + +#: dcim/models/device_components.py:1284 +msgid "Cannot assign inventory item to component on another device" +msgstr "" + +#: dcim/models/devices.py:54 +msgid "manufacturer" +msgstr "" + +#: dcim/models/devices.py:55 +msgid "manufacturers" +msgstr "" + +#: dcim/models/devices.py:82 dcim/models/devices.py:381 +msgid "model" +msgstr "" + +#: dcim/models/devices.py:95 +msgid "default platform" +msgstr "" + +#: dcim/models/devices.py:98 dcim/models/devices.py:385 +msgid "part number" +msgstr "" + +#: dcim/models/devices.py:101 dcim/models/devices.py:388 +msgid "Discrete part number (optional)" +msgstr "" + +#: dcim/models/devices.py:107 dcim/models/racks.py:137 +msgid "height (U)" +msgstr "" + +#: dcim/models/devices.py:111 +msgid "exclude from utilization" +msgstr "" + +#: dcim/models/devices.py:112 +msgid "Exclude from rack utilization calculations." +msgstr "" + +#: dcim/models/devices.py:116 +msgid "is full depth" +msgstr "" + +#: dcim/models/devices.py:117 +msgid "Device consumes both front and rear rack faces" +msgstr "" + +#: dcim/models/devices.py:123 +msgid "parent/child status" +msgstr "" + +#: dcim/models/devices.py:124 +msgid "" +"Parent devices house child devices in device bays. Leave blank if this " +"device type is neither a parent nor a child." +msgstr "" + +#: dcim/models/devices.py:128 dcim/models/devices.py:647 +msgid "airflow" +msgstr "" + +#: dcim/models/devices.py:204 +msgid "device type" +msgstr "" + +#: dcim/models/devices.py:205 +msgid "device types" +msgstr "" + +#: dcim/models/devices.py:289 +msgid "U height must be in increments of 0.5 rack units." +msgstr "" + +#: dcim/models/devices.py:306 +#, python-brace-format +msgid "" +"Device {device} in rack {rack} does not have sufficient space to accommodate " +"a height of {height}U" +msgstr "" + +#: dcim/models/devices.py:321 +#, python-brace-format +msgid "" +"Unable to set 0U height: Found {racked_instance_count} " +"instances already mounted within racks." +msgstr "" + +#: dcim/models/devices.py:330 +msgid "" +"Must delete all device bay templates associated with this device before " +"declassifying it as a parent device." +msgstr "" + +#: dcim/models/devices.py:336 +msgid "Child device types must be 0U." +msgstr "" + +#: dcim/models/devices.py:404 +msgid "module type" +msgstr "" + +#: dcim/models/devices.py:405 +msgid "module types" +msgstr "" + +#: dcim/models/devices.py:473 +msgid "Virtual machines may be assigned to this role" +msgstr "" + +#: dcim/models/devices.py:485 +msgid "device role" +msgstr "" + +#: dcim/models/devices.py:486 +msgid "device roles" +msgstr "" + +#: dcim/models/devices.py:503 +msgid "Optionally limit this platform to devices of a certain manufacturer" +msgstr "" + +#: dcim/models/devices.py:515 +msgid "platform" +msgstr "" + +#: dcim/models/devices.py:516 +msgid "platforms" +msgstr "" + +#: dcim/models/devices.py:564 +msgid "The function this device serves" +msgstr "" + +#: dcim/models/devices.py:596 +msgid "Chassis serial number, assigned by the manufacturer" +msgstr "" + +#: dcim/models/devices.py:604 dcim/models/devices.py:1176 +msgid "A unique tag used to identify this device" +msgstr "" + +#: dcim/models/devices.py:631 +msgid "position (U)" +msgstr "" + +#: dcim/models/devices.py:638 +msgid "rack face" +msgstr "" + +#: dcim/models/devices.py:658 dcim/models/devices.py:1385 +#: virtualization/models/virtualmachines.py:97 +msgid "primary IPv4" +msgstr "" + +#: dcim/models/devices.py:666 dcim/models/devices.py:1393 +#: virtualization/models/virtualmachines.py:105 +msgid "primary IPv6" +msgstr "" + +#: dcim/models/devices.py:674 +msgid "out-of-band IP" +msgstr "" + +#: dcim/models/devices.py:691 +msgid "VC position" +msgstr "" + +#: dcim/models/devices.py:695 +msgid "Virtual chassis position" +msgstr "" + +#: dcim/models/devices.py:698 +msgid "VC priority" +msgstr "" + +#: dcim/models/devices.py:702 +msgid "Virtual chassis master election priority" +msgstr "" + +#: dcim/models/devices.py:705 dcim/models/sites.py:207 +msgid "latitude" +msgstr "" + +#: dcim/models/devices.py:710 dcim/models/devices.py:718 +#: dcim/models/sites.py:212 dcim/models/sites.py:220 +msgid "GPS coordinate in decimal format (xx.yyyyyy)" +msgstr "" + +#: dcim/models/devices.py:713 dcim/models/sites.py:215 +msgid "longitude" +msgstr "" + +#: dcim/models/devices.py:786 +msgid "Device name must be unique per site." +msgstr "" + +#: dcim/models/devices.py:797 ipam/models/services.py:75 +msgid "device" +msgstr "" + +#: dcim/models/devices.py:798 +msgid "devices" +msgstr "" + +#: dcim/models/devices.py:838 +#, python-brace-format +msgid "Rack {rack} does not belong to site {site}." +msgstr "" + +#: dcim/models/devices.py:843 +#, python-brace-format +msgid "Location {location} does not belong to site {site}." +msgstr "" + +#: dcim/models/devices.py:849 +#, python-brace-format +msgid "Rack {rack} does not belong to location {location}." +msgstr "" + +#: dcim/models/devices.py:856 +msgid "Cannot select a rack face without assigning a rack." +msgstr "" + +#: dcim/models/devices.py:860 +msgid "Cannot select a rack position without assigning a rack." +msgstr "" + +#: dcim/models/devices.py:866 +msgid "Position must be in increments of 0.5 rack units." +msgstr "" + +#: dcim/models/devices.py:870 +msgid "Must specify rack face when defining rack position." +msgstr "" + +#: dcim/models/devices.py:878 +#, python-brace-format +msgid "A U0 device type ({device_type}) cannot be assigned to a rack position." +msgstr "" + +#: dcim/models/devices.py:889 +msgid "" +"Child device types cannot be assigned to a rack face. This is an attribute " +"of the parent device." +msgstr "" + +#: dcim/models/devices.py:896 +msgid "" +"Child device types cannot be assigned to a rack position. This is an " +"attribute of the parent device." +msgstr "" + +#: dcim/models/devices.py:910 +#, python-brace-format +msgid "" +"U{position} is already occupied or does not have sufficient space to " +"accommodate this device type: {device_type} ({u_height}U)" +msgstr "" + +#: dcim/models/devices.py:925 +#, python-brace-format +msgid "{ip} is not an IPv4 address." +msgstr "" + +#: dcim/models/devices.py:934 dcim/models/devices.py:949 +#, python-brace-format +msgid "The specified IP address ({ip}) is not assigned to this device." +msgstr "" + +#: dcim/models/devices.py:940 +#, python-brace-format +msgid "{ip} is not an IPv6 address." +msgstr "" + +#: dcim/models/devices.py:967 +#, python-brace-format +msgid "" +"The assigned platform is limited to {platform_manufacturer} device types, " +"but this device's type belongs to {devicetype_manufacturer}." +msgstr "" + +#: dcim/models/devices.py:978 +#, python-brace-format +msgid "The assigned cluster belongs to a different site ({site})" +msgstr "" + +#: dcim/models/devices.py:986 +msgid "A device assigned to a virtual chassis must have its position defined." +msgstr "" + +#: dcim/models/devices.py:1183 +msgid "module" +msgstr "" + +#: dcim/models/devices.py:1184 +msgid "modules" +msgstr "" + +#: dcim/models/devices.py:1200 +#, python-brace-format +msgid "" +"Module must be installed within a module bay belonging to the assigned " +"device ({device})." +msgstr "" + +#: dcim/models/devices.py:1304 +msgid "domain" +msgstr "" + +#: dcim/models/devices.py:1317 dcim/models/devices.py:1318 +msgid "virtual chassis" +msgstr "" + +#: dcim/models/devices.py:1333 +#, python-brace-format +msgid "The selected master ({master}) is not assigned to this virtual chassis." +msgstr "" + +#: dcim/models/devices.py:1349 +#, python-brace-format +msgid "" +"Unable to delete virtual chassis {self}. There are member interfaces which " +"form a cross-chassis LAG interfaces." +msgstr "" + +#: dcim/models/devices.py:1374 ipam/models/l2vpn.py:37 +msgid "identifier" +msgstr "" + +#: dcim/models/devices.py:1375 +msgid "Numeric identifier unique to the parent device" +msgstr "" + +#: dcim/models/devices.py:1403 extras/models/models.py:629 +#: netbox/models/__init__.py:114 +msgid "comments" +msgstr "" + +#: dcim/models/devices.py:1419 +msgid "virtual device context" +msgstr "" + +#: dcim/models/devices.py:1420 +msgid "virtual device contexts" +msgstr "" + +#: dcim/models/devices.py:1452 +#, python-brace-format +msgid "{ip} is not an IPv{family} address." +msgstr "" + +#: dcim/models/devices.py:1458 +msgid "Primary IP address must belong to an interface on the assigned device." +msgstr "" + +#: dcim/models/mixins.py:15 extras/models/configs.py:41 +#: extras/models/models.py:260 extras/models/models.py:469 +#: extras/models/search.py:48 ipam/models/ip.py:193 +msgid "weight" +msgstr "" + +#: dcim/models/mixins.py:22 +msgid "weight unit" +msgstr "" + +#: dcim/models/mixins.py:51 +msgid "Must specify a unit when setting a weight" +msgstr "" + +#: dcim/models/power.py:55 +msgid "power panel" +msgstr "" + +#: dcim/models/power.py:56 +msgid "power panels" +msgstr "" + +#: dcim/models/power.py:70 +#, python-brace-format +msgid "" +"Location {location} ({location_site}) is in a different site than {site}" +msgstr "" + +#: dcim/models/power.py:107 +msgid "supply" +msgstr "" + +#: dcim/models/power.py:113 +msgid "phase" +msgstr "" + +#: dcim/models/power.py:119 +msgid "voltage" +msgstr "" + +#: dcim/models/power.py:124 +msgid "amperage" +msgstr "" + +#: dcim/models/power.py:129 +msgid "max utilization" +msgstr "" + +#: dcim/models/power.py:132 +msgid "Maximum permissible draw (percentage)" +msgstr "" + +#: dcim/models/power.py:135 +msgid "available power" +msgstr "" + +#: dcim/models/power.py:163 +msgid "power feed" +msgstr "" + +#: dcim/models/power.py:164 +msgid "power feeds" +msgstr "" + +#: dcim/models/power.py:178 +#, python-brace-format +msgid "" +"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in " +"different sites" +msgstr "" + +#: dcim/models/power.py:189 +msgid "Voltage cannot be negative for AC supply" +msgstr "" + +#: dcim/models/racks.py:49 +msgid "rack role" +msgstr "" + +#: dcim/models/racks.py:50 +msgid "rack roles" +msgstr "" + +#: dcim/models/racks.py:74 +msgid "facility ID" +msgstr "" + +#: dcim/models/racks.py:75 +msgid "Locally-assigned identifier" +msgstr "" + +#: dcim/models/racks.py:108 ipam/forms/bulk_import.py:203 +#: ipam/forms/bulk_import.py:268 ipam/forms/bulk_import.py:303 +#: ipam/forms/bulk_import.py:470 virtualization/forms/bulk_import.py:111 +msgid "Functional role" +msgstr "" + +#: dcim/models/racks.py:121 +msgid "A unique tag used to identify this rack" +msgstr "" + +#: dcim/models/racks.py:132 +msgid "width" +msgstr "" + +#: dcim/models/racks.py:133 +msgid "Rail-to-rail width" +msgstr "" + +#: dcim/models/racks.py:139 +msgid "Height in rack units" +msgstr "" + +#: dcim/models/racks.py:143 +msgid "starting unit" +msgstr "" + +#: dcim/models/racks.py:144 +msgid "Starting unit for rack" +msgstr "" + +#: dcim/models/racks.py:148 +msgid "descending units" +msgstr "" + +#: dcim/models/racks.py:149 +msgid "Units are numbered top-to-bottom" +msgstr "" + +#: dcim/models/racks.py:152 +msgid "outer width" +msgstr "" + +#: dcim/models/racks.py:155 +msgid "Outer dimension of rack (width)" +msgstr "" + +#: dcim/models/racks.py:158 +msgid "outer depth" +msgstr "" + +#: dcim/models/racks.py:161 +msgid "Outer dimension of rack (depth)" +msgstr "" + +#: dcim/models/racks.py:164 +msgid "outer unit" +msgstr "" + +#: dcim/models/racks.py:170 +msgid "max weight" +msgstr "" + +#: dcim/models/racks.py:173 +msgid "Maximum load capacity for the rack" +msgstr "" + +#: dcim/models/racks.py:181 +msgid "mounting depth" +msgstr "" + +#: dcim/models/racks.py:185 +msgid "" +"Maximum depth of a mounted device, in millimeters. For four-post racks, this " +"is the distance between the front and rear rails." +msgstr "" + +#: dcim/models/racks.py:219 +msgid "rack" +msgstr "" + +#: dcim/models/racks.py:220 +msgid "racks" +msgstr "" + +#: dcim/models/racks.py:235 +#, python-brace-format +msgid "Assigned location must belong to parent site ({site})." +msgstr "" + +#: dcim/models/racks.py:239 +msgid "Must specify a unit when setting an outer width/depth" +msgstr "" + +#: dcim/models/racks.py:243 +msgid "Must specify a unit when setting a maximum weight" +msgstr "" + +#: dcim/models/racks.py:253 +#, python-brace-format +msgid "" +"Rack must be at least {min_height}U tall to house currently installed " +"devices." +msgstr "" + +#: dcim/models/racks.py:260 +#, python-brace-format +msgid "" +"Rack unit numbering must begin at {position} or less to house currently " +"installed devices." +msgstr "" + +#: dcim/models/racks.py:268 +#, python-brace-format +msgid "Location must be from the same site, {site}." +msgstr "" + +#: dcim/models/racks.py:521 +msgid "units" +msgstr "" + +#: dcim/models/racks.py:547 +msgid "rack reservation" +msgstr "" + +#: dcim/models/racks.py:548 +msgid "rack reservations" +msgstr "" + +#: dcim/models/racks.py:565 +#, python-brace-format +msgid "Invalid unit(s) for {height}U rack: {unit_list}" +msgstr "" + +#: dcim/models/racks.py:578 +#, python-brace-format +msgid "The following units have already been reserved: {unit_list}" +msgstr "" + +#: dcim/models/sites.py:49 +msgid "A top-level region with this name already exists." +msgstr "" + +#: dcim/models/sites.py:59 +msgid "A top-level region with this slug already exists." +msgstr "" + +#: dcim/models/sites.py:62 +msgid "region" +msgstr "" + +#: dcim/models/sites.py:63 +msgid "regions" +msgstr "" + +#: dcim/models/sites.py:102 +msgid "A top-level site group with this name already exists." +msgstr "" + +#: dcim/models/sites.py:112 +msgid "A top-level site group with this slug already exists." +msgstr "" + +#: dcim/models/sites.py:115 +msgid "site group" +msgstr "" + +#: dcim/models/sites.py:116 +msgid "site groups" +msgstr "" + +#: dcim/models/sites.py:141 +msgid "Full name of the site" +msgstr "" + +#: dcim/models/sites.py:181 +msgid "facility" +msgstr "" + +#: dcim/models/sites.py:184 +msgid "Local facility ID or description" +msgstr "" + +#: dcim/models/sites.py:195 +msgid "physical address" +msgstr "" + +#: dcim/models/sites.py:198 +msgid "Physical location of the building" +msgstr "" + +#: dcim/models/sites.py:201 +msgid "shipping address" +msgstr "" + +#: dcim/models/sites.py:204 +msgid "If different from the physical address" +msgstr "" + +#: dcim/models/sites.py:238 +msgid "site" +msgstr "" + +#: dcim/models/sites.py:239 +msgid "sites" +msgstr "" + +#: dcim/models/sites.py:303 +msgid "A location with this name already exists within the specified site." +msgstr "" + +#: dcim/models/sites.py:313 +msgid "A location with this slug already exists within the specified site." +msgstr "" + +#: dcim/models/sites.py:316 +msgid "location" +msgstr "" + +#: dcim/models/sites.py:317 +msgid "locations" +msgstr "" + +#: dcim/models/sites.py:331 +#, python-brace-format +msgid "Parent location ({parent}) must belong to the same site ({site})." +msgstr "" + +#: dcim/tables/cables.py:54 +msgid "Termination A" +msgstr "" + +#: dcim/tables/cables.py:59 +msgid "Termination B" +msgstr "" + +#: dcim/tables/cables.py:65 wireless/tables/wirelesslink.py:22 +msgid "Device A" +msgstr "" + +#: dcim/tables/cables.py:71 wireless/tables/wirelesslink.py:31 +msgid "Device B" +msgstr "" + +#: dcim/tables/cables.py:77 +msgid "Location A" +msgstr "" + +#: dcim/tables/cables.py:83 +msgid "Location B" +msgstr "" + +#: dcim/tables/cables.py:89 +msgid "Rack A" +msgstr "" + +#: dcim/tables/cables.py:95 +msgid "Rack B" +msgstr "" + +#: dcim/tables/cables.py:101 +msgid "Site A" +msgstr "" + +#: dcim/tables/cables.py:107 +msgid "Site B" +msgstr "" + +#: dcim/tables/connections.py:27 templates/dcim/consoleport.html:18 +#: templates/dcim/consoleserverport.html:75 templates/dcim/frontport.html:119 +#: templates/dcim/inventoryitem_edit.html:39 +msgid "Console Port" +msgstr "" + +#: dcim/tables/connections.py:31 dcim/tables/connections.py:50 +#: dcim/tables/connections.py:71 +#: templates/dcim/inc/connection_endpoints.html:16 +msgid "Reachable" +msgstr "" + +#: dcim/tables/connections.py:46 dcim/tables/devices.py:518 +#: templates/dcim/inventoryitem_edit.html:64 templates/dcim/poweroutlet.html:47 +#: templates/dcim/powerport.html:18 +msgid "Power Port" +msgstr "" + +#: dcim/tables/devices.py:94 dcim/tables/devices.py:139 dcim/tables/racks.py:81 +#: dcim/tables/sites.py:143 netbox/navigation/menu.py:57 +#: netbox/navigation/menu.py:61 netbox/navigation/menu.py:63 +#: virtualization/forms/model_forms.py:124 virtualization/tables/clusters.py:83 +#: virtualization/views.py:211 +msgid "Devices" +msgstr "" + +#: dcim/tables/devices.py:99 dcim/tables/devices.py:144 +#: virtualization/tables/clusters.py:88 +msgid "VMs" +msgstr "" + +#: dcim/tables/devices.py:133 dcim/tables/devices.py:245 +#: extras/forms/model_forms.py:403 templates/dcim/device.html:131 +#: templates/dcim/device/render_config.html:11 +#: templates/dcim/device/render_config.html:15 +#: templates/dcim/devicerole.html:47 templates/dcim/platform.html:44 +#: templates/extras/configtemplate.html:10 +#: templates/virtualization/virtualmachine.html:47 +#: templates/virtualization/virtualmachine/render_config.html:11 +#: templates/virtualization/virtualmachine/render_config.html:15 +#: virtualization/tables/virtualmachines.py:88 +msgid "Config Template" +msgstr "" + +#: dcim/tables/devices.py:216 dcim/tables/devices.py:1048 +#: ipam/forms/model_forms.py:298 ipam/tables/ip.py:352 ipam/tables/ip.py:418 +#: ipam/tables/ip.py:441 templates/ipam/ipaddress.html:12 +#: templates/ipam/ipaddress_edit.html:14 +#: virtualization/tables/virtualmachines.py:79 +msgid "IP Address" +msgstr "" + +#: dcim/tables/devices.py:220 dcim/tables/devices.py:1052 +#: virtualization/tables/virtualmachines.py:70 +msgid "IPv4 Address" +msgstr "" + +#: dcim/tables/devices.py:224 dcim/tables/devices.py:1056 +#: virtualization/tables/virtualmachines.py:74 +msgid "IPv6 Address" +msgstr "" + +#: dcim/tables/devices.py:239 +msgid "VC Position" +msgstr "" + +#: dcim/tables/devices.py:242 +msgid "VC Priority" +msgstr "" + +#: dcim/tables/devices.py:249 templates/dcim/device_edit.html:38 +#: templates/dcim/devicebay_populate.html:16 +msgid "Parent Device" +msgstr "" + +#: dcim/tables/devices.py:254 +msgid "Position (Device Bay)" +msgstr "" + +#: dcim/tables/devices.py:263 +msgid "Console ports" +msgstr "" + +#: dcim/tables/devices.py:266 +msgid "Console server ports" +msgstr "" + +#: dcim/tables/devices.py:269 +msgid "Power ports" +msgstr "" + +#: dcim/tables/devices.py:272 +msgid "Power outlets" +msgstr "" + +#: dcim/tables/devices.py:275 dcim/tables/devices.py:1061 +#: dcim/tables/devicetypes.py:125 dcim/views.py:1002 dcim/views.py:1241 +#: dcim/views.py:1927 netbox/navigation/menu.py:82 +#: netbox/navigation/menu.py:220 templates/dcim/device/base.html:37 +#: templates/dcim/device_list.html:43 templates/dcim/devicetype/base.html:34 +#: templates/dcim/module.html:34 templates/dcim/moduletype/base.html:34 +#: templates/dcim/virtualdevicecontext.html:64 +#: templates/dcim/virtualdevicecontext.html:85 +#: templates/virtualization/virtualmachine_list.html:14 +#: virtualization/tables/virtualmachines.py:85 virtualization/views.py:368 +#: wireless/tables/wirelesslan.py:55 +msgid "Interfaces" +msgstr "" + +#: dcim/tables/devices.py:278 +msgid "Front ports" +msgstr "" + +#: dcim/tables/devices.py:284 +msgid "Device bays" +msgstr "" + +#: dcim/tables/devices.py:287 +msgid "Module bays" +msgstr "" + +#: dcim/tables/devices.py:290 +msgid "Inventory items" +msgstr "" + +#: dcim/tables/devices.py:329 dcim/tables/modules.py:56 +#: templates/dcim/modulebay.html:17 +msgid "Module Bay" +msgstr "" + +#: dcim/tables/devices.py:350 +msgid "Cable Color" +msgstr "" + +#: dcim/tables/devices.py:356 +msgid "Link Peers" +msgstr "" + +#: dcim/tables/devices.py:359 +msgid "Mark Connected" +msgstr "" + +#: dcim/tables/devices.py:567 ipam/forms/model_forms.py:709 +#: ipam/tables/fhrp.py:28 ipam/views.py:599 ipam/views.py:673 +#: netbox/navigation/menu.py:146 netbox/navigation/menu.py:148 +#: templates/dcim/interface.html:347 templates/ipam/ipaddress_bulk_add.html:15 +#: templates/ipam/service.html:43 templates/virtualization/vminterface.html:84 +msgid "IP Addresses" +msgstr "" + +#: dcim/tables/devices.py:573 netbox/navigation/menu.py:190 +#: templates/ipam/inc/panels/fhrp_groups.html:5 +msgid "FHRP Groups" +msgstr "" + +#: dcim/tables/devices.py:604 dcim/tables/devicetypes.py:224 +#: templates/dcim/interface.html:66 +msgid "Management Only" +msgstr "" + +#: dcim/tables/devices.py:612 +msgid "Wireless link" +msgstr "" + +#: dcim/tables/devices.py:622 +msgid "VDCs" +msgstr "" + +#: dcim/tables/devices.py:706 +#: templates/circuits/inc/circuit_termination.html:80 +#: templates/dcim/consoleport.html:81 templates/dcim/consoleserverport.html:81 +#: templates/dcim/frontport.html:53 templates/dcim/frontport.html:125 +#: templates/dcim/interface.html:192 templates/dcim/inventoryitem_edit.html:69 +#: templates/dcim/rearport.html:18 templates/dcim/rearport.html:115 +msgid "Rear Port" +msgstr "" + +#: dcim/tables/devices.py:871 templates/dcim/modulebay.html:51 +msgid "Installed Module" +msgstr "" + +#: dcim/tables/devices.py:874 +msgid "Module Serial" +msgstr "" + +#: dcim/tables/devices.py:878 +msgid "Module Asset Tag" +msgstr "" + +#: dcim/tables/devices.py:887 +msgid "Module Status" +msgstr "" + +#: dcim/tables/devices.py:929 dcim/tables/devicetypes.py:308 +#: templates/dcim/inventoryitem.html:41 +msgid "Component" +msgstr "" + +#: dcim/tables/devices.py:980 +msgid "Items" +msgstr "" + +#: dcim/tables/devicetypes.py:38 netbox/navigation/menu.py:72 +#: netbox/navigation/menu.py:74 +msgid "Device Types" +msgstr "" + +#: dcim/tables/devicetypes.py:43 netbox/navigation/menu.py:75 +msgid "Module Types" +msgstr "" + +#: dcim/tables/devicetypes.py:48 dcim/tables/devicetypes.py:140 +#: dcim/views.py:1077 dcim/views.py:2020 netbox/navigation/menu.py:91 +#: templates/dcim/device/base.html:52 templates/dcim/device_list.html:71 +#: templates/dcim/devicetype/base.html:49 +#: templates/dcim/inc/panels/inventory_items.html:5 +#: templates/dcim/inventoryitemrole.html:33 +msgid "Inventory Items" +msgstr "" + +#: dcim/tables/devicetypes.py:53 extras/forms/filtersets.py:354 +#: extras/forms/model_forms.py:311 netbox/navigation/menu.py:66 +msgid "Platforms" +msgstr "" + +#: dcim/tables/devicetypes.py:85 templates/dcim/devicetype.html:32 +msgid "Default Platform" +msgstr "" + +#: dcim/tables/devicetypes.py:89 templates/dcim/devicetype.html:48 +msgid "Full Depth" +msgstr "" + +#: dcim/tables/devicetypes.py:98 +msgid "U Height" +msgstr "" + +#: dcim/tables/devicetypes.py:110 dcim/tables/modules.py:26 +msgid "Instances" +msgstr "" + +#: dcim/tables/devicetypes.py:113 dcim/views.py:942 dcim/views.py:1181 +#: dcim/views.py:1867 netbox/navigation/menu.py:85 +#: templates/dcim/device/base.html:25 templates/dcim/device_list.html:15 +#: templates/dcim/devicetype/base.html:22 templates/dcim/module.html:22 +#: templates/dcim/moduletype/base.html:22 +msgid "Console Ports" +msgstr "" + +#: dcim/tables/devicetypes.py:116 dcim/views.py:957 dcim/views.py:1196 +#: dcim/views.py:1882 netbox/navigation/menu.py:86 +#: templates/dcim/device/base.html:28 templates/dcim/device_list.html:22 +#: templates/dcim/devicetype/base.html:25 templates/dcim/module.html:25 +#: templates/dcim/moduletype/base.html:25 +msgid "Console Server Ports" +msgstr "" + +#: dcim/tables/devicetypes.py:119 dcim/views.py:972 dcim/views.py:1211 +#: dcim/views.py:1897 netbox/navigation/menu.py:87 +#: templates/dcim/device/base.html:31 templates/dcim/device_list.html:29 +#: templates/dcim/devicetype/base.html:28 templates/dcim/module.html:28 +#: templates/dcim/moduletype/base.html:28 +msgid "Power Ports" +msgstr "" + +#: dcim/tables/devicetypes.py:122 dcim/views.py:987 dcim/views.py:1226 +#: dcim/views.py:1912 netbox/navigation/menu.py:88 +#: templates/dcim/device/base.html:34 templates/dcim/device_list.html:36 +#: templates/dcim/devicetype/base.html:31 templates/dcim/module.html:31 +#: templates/dcim/moduletype/base.html:31 +msgid "Power Outlets" +msgstr "" + +#: dcim/tables/devicetypes.py:128 dcim/views.py:1017 dcim/views.py:1256 +#: dcim/views.py:1948 netbox/navigation/menu.py:83 +#: templates/dcim/device/base.html:40 templates/dcim/devicetype/base.html:37 +#: templates/dcim/module.html:37 templates/dcim/moduletype/base.html:37 +msgid "Front Ports" +msgstr "" + +#: dcim/tables/devicetypes.py:131 dcim/views.py:1032 dcim/views.py:1271 +#: dcim/views.py:1963 netbox/navigation/menu.py:84 +#: templates/dcim/device/base.html:43 templates/dcim/device_list.html:50 +#: templates/dcim/devicetype/base.html:40 templates/dcim/module.html:40 +#: templates/dcim/moduletype/base.html:40 +msgid "Rear Ports" +msgstr "" + +#: dcim/tables/devicetypes.py:134 dcim/views.py:1062 dcim/views.py:2001 +#: netbox/navigation/menu.py:90 templates/dcim/device/base.html:49 +#: templates/dcim/device_list.html:57 templates/dcim/devicetype/base.html:46 +msgid "Device Bays" +msgstr "" + +#: dcim/tables/devicetypes.py:137 dcim/views.py:1047 dcim/views.py:1982 +#: netbox/navigation/menu.py:89 templates/dcim/device/base.html:46 +#: templates/dcim/device_list.html:64 templates/dcim/devicetype/base.html:43 +msgid "Module Bays" +msgstr "" + +#: dcim/tables/power.py:36 netbox/navigation/menu.py:263 +#: templates/dcim/powerpanel.html:53 templates/extras/configrevision.html:59 +msgid "Power Feeds" +msgstr "" + +#: dcim/tables/power.py:80 templates/dcim/powerfeed.html:106 +msgid "Max Utilization" +msgstr "" + +#: dcim/tables/power.py:84 +msgid "Available Power (VA)" +msgstr "" + +#: dcim/tables/racks.py:29 dcim/tables/sites.py:138 +#: netbox/navigation/menu.py:25 netbox/navigation/menu.py:27 +msgid "Racks" +msgstr "" + +#: dcim/tables/racks.py:73 templates/dcim/device.html:340 +#: templates/dcim/rack.html:102 +msgid "Height" +msgstr "" + +#: dcim/tables/racks.py:85 +msgid "Space" +msgstr "" + +#: dcim/tables/racks.py:96 templates/dcim/rack.html:112 +msgid "Outer Width" +msgstr "" + +#: dcim/tables/racks.py:100 templates/dcim/rack.html:122 +msgid "Outer Depth" +msgstr "" + +#: dcim/tables/racks.py:108 +msgid "Max Weight" +msgstr "" + +#: dcim/tables/sites.py:30 dcim/tables/sites.py:57 +#: extras/forms/filtersets.py:334 extras/forms/model_forms.py:291 +#: ipam/forms/bulk_edit.py:130 ipam/forms/model_forms.py:154 +#: ipam/tables/asn.py:65 netbox/navigation/menu.py:16 +#: netbox/navigation/menu.py:18 +msgid "Sites" +msgstr "" + +#: dcim/views.py:131 +#, python-brace-format +msgid "Disconnected {count} {type}" +msgstr "" + +#: dcim/views.py:692 netbox/navigation/menu.py:29 +msgid "Reservations" +msgstr "" + +#: dcim/views.py:711 +msgid "Non-Racked Devices" +msgstr "" + +#: dcim/views.py:2033 extras/forms/model_forms.py:351 +#: templates/extras/configcontext.html:10 +#: virtualization/forms/model_forms.py:226 virtualization/views.py:386 +msgid "Config Context" +msgstr "" + +#: dcim/views.py:2043 virtualization/views.py:396 +msgid "Render Config" +msgstr "" + +#: extras/choices.py:27 extras/forms/misc.py:14 +msgid "Text" +msgstr "" + +#: extras/choices.py:28 +msgid "Text (long)" +msgstr "" + +#: extras/choices.py:29 +msgid "Integer" +msgstr "" + +#: extras/choices.py:30 +msgid "Decimal" +msgstr "" + +#: extras/choices.py:31 +msgid "Boolean (true/false)" +msgstr "" + +#: extras/choices.py:32 +msgid "Date" +msgstr "" + +#: extras/choices.py:33 +msgid "Date & time" +msgstr "" + +#: extras/choices.py:35 +msgid "JSON" +msgstr "" + +#: extras/choices.py:36 +msgid "Selection" +msgstr "" + +#: extras/choices.py:37 +msgid "Multiple selection" +msgstr "" + +#: extras/choices.py:39 +msgid "Multiple objects" +msgstr "" + +#: extras/choices.py:50 templates/extras/customfield.html:69 +#: wireless/choices.py:27 +msgid "Disabled" +msgstr "" + +#: extras/choices.py:51 +msgid "Loose" +msgstr "" + +#: extras/choices.py:52 +msgid "Exact" +msgstr "" + +#: extras/choices.py:64 +msgid "Read/write" +msgstr "" + +#: extras/choices.py:65 +msgid "Read-only" +msgstr "" + +#: extras/choices.py:66 +msgid "Hidden" +msgstr "" + +#: extras/choices.py:67 +msgid "Hidden (if unset)" +msgstr "" + +#: extras/choices.py:94 templates/tenancy/contact.html:58 +#: tenancy/forms/bulk_edit.py:117 wireless/forms/model_forms.py:159 +msgid "Link" +msgstr "" + +#: extras/choices.py:108 +msgid "Newest" +msgstr "" + +#: extras/choices.py:109 +msgid "Oldest" +msgstr "" + +#: extras/choices.py:125 templates/generic/object.html:51 +msgid "Updated" +msgstr "" + +#: extras/choices.py:126 +msgid "Deleted" +msgstr "" + +#: extras/choices.py:143 extras/choices.py:165 +msgid "Info" +msgstr "" + +#: extras/choices.py:144 extras/choices.py:164 +msgid "Success" +msgstr "" + +#: extras/choices.py:145 extras/choices.py:166 +msgid "Warning" +msgstr "" + +#: extras/choices.py:146 +msgid "Danger" +msgstr "" + +#: extras/choices.py:163 utilities/choices.py:190 +msgid "Default" +msgstr "" + +#: extras/choices.py:167 +msgid "Failure" +msgstr "" + +#: extras/choices.py:174 +msgid "Hourly" +msgstr "" + +#: extras/choices.py:175 +msgid "12 hours" +msgstr "" + +#: extras/choices.py:176 +msgid "Daily" +msgstr "" + +#: extras/choices.py:177 +msgid "Weekly" +msgstr "" + +#: extras/choices.py:178 +msgid "30 days" +msgstr "" + +#: extras/choices.py:243 extras/tables/tables.py:283 +#: templates/dcim/virtualchassis_edit.html:108 templates/extras/webhook.html:33 +#: templates/generic/bulk_add_component.html:56 +#: templates/generic/object_edit.html:29 templates/generic/object_edit.html:70 +#: templates/ipam/inc/ipaddress_edit_header.html:10 +msgid "Create" +msgstr "" + +#: extras/choices.py:244 extras/tables/tables.py:286 +#: templates/extras/webhook.html:37 +msgid "Update" +msgstr "" + +#: extras/choices.py:245 extras/tables/tables.py:289 +#: templates/circuits/inc/circuit_termination.html:22 +#: templates/dcim/devicetype/component_templates.html:24 +#: templates/dcim/inc/panels/inventory_items.html:29 +#: templates/dcim/moduletype/component_templates.html:24 +#: templates/dcim/powerpanel.html:71 templates/extras/report_list.html:34 +#: templates/extras/script_list.html:33 templates/extras/webhook.html:41 +#: templates/generic/bulk_delete.html:18 templates/generic/bulk_delete.html:45 +#: templates/generic/object_delete.html:15 templates/htmx/delete_form.html:23 +#: templates/ipam/inc/panels/fhrp_groups.html:35 +#: templates/users/objectpermission.html:49 +#: utilities/templates/buttons/delete.html:9 +msgid "Delete" +msgstr "" + +#: extras/choices.py:269 utilities/choices.py:143 utilities/choices.py:191 +msgid "Blue" +msgstr "" + +#: extras/choices.py:270 utilities/choices.py:142 utilities/choices.py:192 +msgid "Indigo" +msgstr "" + +#: extras/choices.py:271 utilities/choices.py:140 utilities/choices.py:193 +msgid "Purple" +msgstr "" + +#: extras/choices.py:272 utilities/choices.py:137 utilities/choices.py:194 +msgid "Pink" +msgstr "" + +#: extras/choices.py:273 utilities/choices.py:136 utilities/choices.py:195 +msgid "Red" +msgstr "" + +#: extras/choices.py:274 utilities/choices.py:154 utilities/choices.py:196 +msgid "Orange" +msgstr "" + +#: extras/choices.py:275 utilities/choices.py:152 utilities/choices.py:197 +msgid "Yellow" +msgstr "" + +#: extras/choices.py:276 utilities/choices.py:149 utilities/choices.py:198 +msgid "Green" +msgstr "" + +#: extras/choices.py:277 utilities/choices.py:146 utilities/choices.py:199 +msgid "Teal" +msgstr "" + +#: extras/choices.py:278 utilities/choices.py:145 utilities/choices.py:200 +msgid "Cyan" +msgstr "" + +#: extras/choices.py:279 utilities/choices.py:201 +msgid "Gray" +msgstr "" + +#: extras/choices.py:280 utilities/choices.py:160 utilities/choices.py:202 +msgid "Black" +msgstr "" + +#: extras/choices.py:281 utilities/choices.py:161 utilities/choices.py:203 +msgid "White" +msgstr "" + +#: extras/dashboard/forms.py:38 +msgid "Widget type" +msgstr "" + +#: extras/dashboard/widgets.py:146 +msgid "Note" +msgstr "" + +#: extras/dashboard/widgets.py:147 +msgid "Display some arbitrary custom content. Markdown is supported." +msgstr "" + +#: extras/dashboard/widgets.py:160 +msgid "Object Counts" +msgstr "" + +#: extras/dashboard/widgets.py:161 +msgid "" +"Display a set of NetBox models and the number of objects created for each " +"type." +msgstr "" + +#: extras/dashboard/widgets.py:171 +msgid "Filters to apply when counting the number of objects" +msgstr "" + +#: extras/dashboard/widgets.py:207 +msgid "Object List" +msgstr "" + +#: extras/dashboard/widgets.py:208 +msgid "Display an arbitrary list of objects." +msgstr "" + +#: extras/dashboard/widgets.py:221 +msgid "The default number of objects to display" +msgstr "" + +#: extras/dashboard/widgets.py:268 +msgid "RSS Feed" +msgstr "" + +#: extras/dashboard/widgets.py:273 +msgid "Embed an RSS feed from an external website." +msgstr "" + +#: extras/dashboard/widgets.py:280 +msgid "Feed URL" +msgstr "" + +#: extras/dashboard/widgets.py:285 +msgid "The maximum number of objects to display" +msgstr "" + +#: extras/dashboard/widgets.py:290 +msgid "How long to stored the cached content (in seconds)" +msgstr "" + +#: extras/dashboard/widgets.py:342 templates/account/base.html:10 +#: templates/account/bookmarks.html:7 templates/inc/profile_button.html:29 +msgid "Bookmarks" +msgstr "" + +#: extras/dashboard/widgets.py:346 +msgid "Show your personal bookmarks" +msgstr "" + +#: extras/filtersets.py:176 extras/filtersets.py:511 extras/filtersets.py:539 +msgid "Data file (ID)" +msgstr "" + +#: extras/filtersets.py:448 virtualization/forms/filtersets.py:111 +msgid "Cluster type" +msgstr "" + +#: extras/filtersets.py:454 virtualization/filtersets.py:93 +#: virtualization/filtersets.py:143 +msgid "Cluster type (slug)" +msgstr "" + +#: extras/filtersets.py:459 ipam/forms/bulk_edit.py:477 +#: ipam/forms/model_forms.py:587 virtualization/forms/filtersets.py:105 +msgid "Cluster group" +msgstr "" + +#: extras/filtersets.py:465 virtualization/filtersets.py:132 +msgid "Cluster group (slug)" +msgstr "" + +#: extras/filtersets.py:475 tenancy/forms/forms.py:16 tenancy/forms/forms.py:39 +msgid "Tenant group" +msgstr "" + +#: extras/filtersets.py:481 tenancy/filtersets.py:151 tenancy/filtersets.py:171 +msgid "Tenant group (slug)" +msgstr "" + +#: extras/filtersets.py:497 templates/extras/tag.html:12 +msgid "Tag" +msgstr "" + +#: extras/filtersets.py:503 +msgid "Tag (slug)" +msgstr "" + +#: extras/filtersets.py:563 extras/forms/filtersets.py:413 +msgid "Has local config context data" +msgstr "" + +#: extras/filtersets.py:588 +msgid "User name" +msgstr "" + +#: extras/forms/bulk_edit.py:31 extras/forms/filtersets.py:58 +msgid "Group name" +msgstr "" + +#: extras/forms/bulk_edit.py:39 extras/forms/filtersets.py:66 +#: extras/tables/tables.py:72 templates/extras/customfield.html:39 +#: templates/generic/bulk_import.html:116 +msgid "Required" +msgstr "" + +#: extras/forms/bulk_edit.py:52 extras/forms/bulk_import.py:56 +#: extras/forms/filtersets.py:80 extras/models/customfields.py:187 +msgid "UI visibility" +msgstr "" + +#: extras/forms/bulk_edit.py:58 extras/forms/filtersets.py:83 +msgid "Is cloneable" +msgstr "" + +#: extras/forms/bulk_edit.py:97 extras/forms/filtersets.py:123 +msgid "New window" +msgstr "" + +#: extras/forms/bulk_edit.py:106 +msgid "Button class" +msgstr "" + +#: extras/forms/bulk_edit.py:123 extras/forms/filtersets.py:161 +#: extras/models/models.py:356 +msgid "MIME type" +msgstr "" + +#: extras/forms/bulk_edit.py:128 extras/forms/filtersets.py:164 +msgid "File extension" +msgstr "" + +#: extras/forms/bulk_edit.py:133 extras/forms/filtersets.py:168 +msgid "As attachment" +msgstr "" + +#: extras/forms/bulk_edit.py:161 extras/forms/filtersets.py:210 +#: extras/tables/tables.py:236 templates/extras/savedfilter.html:30 +msgid "Shared" +msgstr "" + +#: extras/forms/bulk_edit.py:182 +msgid "On create" +msgstr "" + +#: extras/forms/bulk_edit.py:187 +msgid "On update" +msgstr "" + +#: extras/forms/bulk_edit.py:192 +msgid "On delete" +msgstr "" + +#: extras/forms/bulk_edit.py:197 +msgid "On job start" +msgstr "" + +#: extras/forms/bulk_edit.py:202 +msgid "On job end" +msgstr "" + +#: extras/forms/bulk_edit.py:209 extras/forms/filtersets.py:239 +#: extras/models/models.py:100 +msgid "HTTP method" +msgstr "" + +#: extras/forms/bulk_edit.py:213 templates/extras/webhook.html:66 +msgid "Payload URL" +msgstr "" + +#: extras/forms/bulk_edit.py:218 extras/models/models.py:146 +msgid "SSL verification" +msgstr "" + +#: extras/forms/bulk_edit.py:221 templates/extras/webhook.html:74 +msgid "Secret" +msgstr "" + +#: extras/forms/bulk_edit.py:226 +msgid "CA file path" +msgstr "" + +#: extras/forms/bulk_edit.py:261 +msgid "Is active" +msgstr "" + +#: extras/forms/bulk_import.py:31 extras/forms/bulk_import.py:91 +#: extras/forms/bulk_import.py:107 extras/forms/bulk_import.py:131 +#: extras/forms/bulk_import.py:145 extras/forms/filtersets.py:111 +#: extras/forms/filtersets.py:157 extras/forms/filtersets.py:198 +#: extras/forms/model_forms.py:46 extras/forms/model_forms.py:119 +#: extras/forms/model_forms.py:147 extras/forms/model_forms.py:189 +#: extras/forms/model_forms.py:227 +msgid "Content types" +msgstr "" + +#: extras/forms/bulk_import.py:34 extras/forms/bulk_import.py:94 +#: extras/forms/bulk_import.py:110 extras/forms/bulk_import.py:133 +#: extras/forms/bulk_import.py:148 tenancy/forms/bulk_import.py:96 +msgid "One or more assigned object types" +msgstr "" + +#: extras/forms/bulk_import.py:39 +msgid "Field data type (e.g. text, integer, etc.)" +msgstr "" + +#: extras/forms/bulk_import.py:42 extras/forms/filtersets.py:50 +#: extras/forms/filtersets.py:234 extras/forms/model_forms.py:51 +#: extras/forms/model_forms.py:215 tenancy/forms/filtersets.py:93 +msgid "Object type" +msgstr "" + +#: extras/forms/bulk_import.py:46 +msgid "Object type (for object or multi-object fields)" +msgstr "" + +#: extras/forms/bulk_import.py:49 extras/forms/filtersets.py:75 +msgid "Choice set" +msgstr "" + +#: extras/forms/bulk_import.py:53 +msgid "Choice set (for selection fields)" +msgstr "" + +#: extras/forms/bulk_import.py:58 +msgid "How the custom field is displayed in the user interface" +msgstr "" + +#: extras/forms/bulk_import.py:74 +msgid "The base set of predefined choices to use (if any)" +msgstr "" + +#: extras/forms/bulk_import.py:79 +msgid "Comma-separated list of field choices" +msgstr "" + +#: extras/forms/bulk_import.py:174 +msgid "Assigned object type" +msgstr "" + +#: extras/forms/bulk_import.py:179 +msgid "The classification of entry" +msgstr "" + +#: extras/forms/filtersets.py:55 +msgid "Field type" +msgstr "" + +#: extras/forms/filtersets.py:94 extras/tables/tables.py:87 +#: templates/generic/bulk_import.html:148 +msgid "Choices" +msgstr "" + +#: extras/forms/filtersets.py:138 extras/forms/filtersets.py:302 +#: extras/forms/filtersets.py:392 extras/forms/model_forms.py:346 +#: templates/core/job.html:80 templates/extras/configcontext.html:86 +msgid "Data" +msgstr "" + +#: extras/forms/filtersets.py:149 extras/forms/filtersets.py:316 +#: extras/forms/filtersets.py:402 utilities/choices.py:219 +#: utilities/forms/bulk_import.py:27 +msgid "Data file" +msgstr "" + +#: extras/forms/filtersets.py:182 +msgid "Content type" +msgstr "" + +#: extras/forms/filtersets.py:229 extras/forms/model_forms.py:234 +#: templates/extras/webhook.html:28 +msgid "Events" +msgstr "" + +#: extras/forms/filtersets.py:253 +msgid "Object creations" +msgstr "" + +#: extras/forms/filtersets.py:260 +msgid "Object updates" +msgstr "" + +#: extras/forms/filtersets.py:267 +msgid "Object deletions" +msgstr "" + +#: extras/forms/filtersets.py:274 +msgid "Job starts" +msgstr "" + +#: extras/forms/filtersets.py:281 extras/forms/model_forms.py:250 +msgid "Job terminations" +msgstr "" + +#: extras/forms/filtersets.py:290 +msgid "Tagged object type" +msgstr "" + +#: extras/forms/filtersets.py:295 +msgid "Allowed object type" +msgstr "" + +#: extras/forms/filtersets.py:324 extras/forms/model_forms.py:281 +#: netbox/navigation/menu.py:19 +msgid "Regions" +msgstr "" + +#: extras/forms/filtersets.py:329 extras/forms/model_forms.py:286 +msgid "Site groups" +msgstr "" + +#: extras/forms/filtersets.py:339 extras/forms/model_forms.py:296 +#: netbox/navigation/menu.py:21 +msgid "Locations" +msgstr "" + +#: extras/forms/filtersets.py:344 extras/forms/model_forms.py:301 +msgid "Device types" +msgstr "" + +#: extras/forms/filtersets.py:349 extras/forms/model_forms.py:306 +msgid "Roles" +msgstr "" + +#: extras/forms/filtersets.py:359 extras/forms/model_forms.py:316 +msgid "Cluster types" +msgstr "" + +#: extras/forms/filtersets.py:365 extras/forms/model_forms.py:321 +msgid "Cluster groups" +msgstr "" + +#: extras/forms/filtersets.py:370 extras/forms/model_forms.py:326 +#: netbox/navigation/menu.py:224 netbox/navigation/menu.py:226 +#: templates/virtualization/clustertype.html:33 +#: virtualization/tables/clusters.py:23 virtualization/tables/clusters.py:45 +msgid "Clusters" +msgstr "" + +#: extras/forms/filtersets.py:375 extras/forms/model_forms.py:331 +msgid "Tenant groups" +msgstr "" + +#: extras/forms/filtersets.py:429 extras/forms/filtersets.py:470 +msgid "After" +msgstr "" + +#: extras/forms/filtersets.py:434 extras/forms/filtersets.py:475 +msgid "Before" +msgstr "" + +#: extras/forms/filtersets.py:465 extras/tables/tables.py:426 +#: templates/extras/htmx/report_result.html:43 +#: templates/extras/objectchange.html:34 +msgid "Time" +msgstr "" + +#: extras/forms/filtersets.py:479 extras/tables/tables.py:440 +#: templates/extras/objectchange.html:50 +msgid "Action" +msgstr "" + +#: extras/forms/mixins.py:71 extras/forms/model_forms.py:195 +#: templates/extras/savedfilter.html:10 +msgid "Saved Filter" +msgstr "" + +#: extras/forms/model_forms.py:56 +msgid "Type of the related object (for object/multi-object fields only)" +msgstr "" + +#: extras/forms/model_forms.py:64 templates/extras/customfield.html:11 +msgid "Custom Field" +msgstr "" + +#: extras/forms/model_forms.py:67 templates/extras/customfield.html:60 +msgid "Behavior" +msgstr "" + +#: extras/forms/model_forms.py:68 +msgid "Values" +msgstr "" + +#: extras/forms/model_forms.py:69 extras/forms/model_forms.py:494 +#: templates/extras/configrevision.html:147 +msgid "Validation" +msgstr "" + +#: extras/forms/model_forms.py:77 +msgid "" +"The type of data stored in this field. For object/multi-object fields, " +"select the related object type below." +msgstr "" + +#: extras/forms/model_forms.py:80 +msgid "" +"This will be displayed as help text for the form field. Markdown is " +"supported." +msgstr "" + +#: extras/forms/model_forms.py:97 +msgid "" +"Enter one choice per line. An optional label may be specified for each " +"choice by appending it with a comma. Example:" +msgstr "" + +#: extras/forms/model_forms.py:125 templates/extras/customlink.html:10 +msgid "Custom Link" +msgstr "" + +#: extras/forms/model_forms.py:126 +msgid "Templates" +msgstr "" + +#: extras/forms/model_forms.py:138 +msgid "" +"Jinja2 template code for the link text. Reference the object as " +"{{ object }}. Links which render as empty text will not be " +"displayed." +msgstr "" + +#: extras/forms/model_forms.py:141 +msgid "" +"Jinja2 template code for the link URL. Reference the object as " +"{{ object }}." +msgstr "" + +#: extras/forms/model_forms.py:152 extras/forms/model_forms.py:397 +msgid "Template code" +msgstr "" + +#: extras/forms/model_forms.py:158 templates/extras/exporttemplate.html:17 +msgid "Export Template" +msgstr "" + +#: extras/forms/model_forms.py:160 +msgid "Rendering" +msgstr "" + +#: extras/forms/model_forms.py:174 extras/forms/model_forms.py:422 +msgid "Template content is populated from the remote source selected below." +msgstr "" + +#: extras/forms/model_forms.py:181 extras/forms/model_forms.py:429 +msgid "Must specify either local content or a data file" +msgstr "" + +#: extras/forms/model_forms.py:233 templates/extras/webhook.html:11 +msgid "Webhook" +msgstr "" + +#: extras/forms/model_forms.py:235 templates/extras/webhook.html:57 +msgid "HTTP Request" +msgstr "" + +#: extras/forms/model_forms.py:238 templates/extras/webhook.html:116 +msgid "Conditions" +msgstr "" + +#: extras/forms/model_forms.py:239 templates/extras/webhook.html:82 +msgid "SSL" +msgstr "" + +#: extras/forms/model_forms.py:246 +msgid "Creations" +msgstr "" + +#: extras/forms/model_forms.py:247 +msgid "Updates" +msgstr "" + +#: extras/forms/model_forms.py:248 +msgid "Deletions" +msgstr "" + +#: extras/forms/model_forms.py:249 +msgid "Job executions" +msgstr "" + +#: extras/forms/model_forms.py:262 users/forms/model_forms.py:285 +msgid "Object types" +msgstr "" + +#: extras/forms/model_forms.py:336 netbox/navigation/menu.py:40 +#: tenancy/tables/tenants.py:22 +msgid "Tenants" +msgstr "" + +#: extras/forms/model_forms.py:353 ipam/forms/filtersets.py:145 +#: templates/extras/configcontext.html:62 templates/ipam/ipaddress.html:62 +#: templates/ipam/vlan_edit.html:30 tenancy/forms/filtersets.py:87 +#: users/forms/model_forms.py:323 +msgid "Assignment" +msgstr "" + +#: extras/forms/model_forms.py:379 +msgid "Data is populated from the remote source selected below." +msgstr "" + +#: extras/forms/model_forms.py:385 +msgid "Must specify either local data or a data file" +msgstr "" + +#: extras/forms/model_forms.py:404 templates/core/datafile.html:65 +msgid "Content" +msgstr "" + +#: extras/forms/model_forms.py:488 templates/dcim/rack_elevation_list.html:6 +#: templates/extras/configrevision.html:43 +msgid "Rack Elevations" +msgstr "" + +#: extras/forms/model_forms.py:490 netbox/navigation/menu.py:142 +#: templates/extras/configrevision.html:79 +msgid "IPAM" +msgstr "" + +#: extras/forms/model_forms.py:491 templates/extras/configrevision.html:95 +msgid "Security" +msgstr "" + +#: extras/forms/model_forms.py:492 templates/extras/configrevision.html:107 +msgid "Banners" +msgstr "" + +#: extras/forms/model_forms.py:493 templates/extras/configrevision.html:131 +msgid "Pagination" +msgstr "" + +#: extras/forms/model_forms.py:495 templates/account/preferences.html:6 +#: templates/extras/configrevision.html:159 +msgid "User Preferences" +msgstr "" + +#: extras/forms/model_forms.py:499 +msgid "Config Revision" +msgstr "" + +#: extras/forms/model_forms.py:537 +msgid "This parameter has been defined statically and cannot be modified." +msgstr "" + +#: extras/forms/model_forms.py:545 +#, python-brace-format +msgid "Current value: {value}" +msgstr "" + +#: extras/forms/model_forms.py:547 +msgid " (default)" +msgstr "" + +#: extras/forms/reports.py:18 extras/forms/scripts.py:24 +msgid "Schedule at" +msgstr "" + +#: extras/forms/reports.py:19 +msgid "Schedule execution of report to a set time" +msgstr "" + +#: extras/forms/reports.py:24 extras/forms/scripts.py:30 +msgid "Recurs every" +msgstr "" + +#: extras/forms/reports.py:28 +msgid "Interval at which this report is re-run (in minutes)" +msgstr "" + +#: extras/forms/reports.py:36 extras/forms/scripts.py:42 +#, python-brace-format +msgid " (current time: {now})" +msgstr "" + +#: extras/forms/reports.py:46 extras/forms/scripts.py:52 +msgid "Scheduled time must be in the future." +msgstr "" + +#: extras/forms/scripts.py:18 +msgid "Commit changes" +msgstr "" + +#: extras/forms/scripts.py:19 +msgid "Commit changes to the database (uncheck for a dry-run)" +msgstr "" + +#: extras/forms/scripts.py:25 +msgid "Schedule execution of script to a set time" +msgstr "" + +#: extras/forms/scripts.py:34 +msgid "Interval at which this script is re-run (in minutes)" +msgstr "" + +#: extras/models/change_logging.py:23 +msgid "time" +msgstr "" + +#: extras/models/change_logging.py:36 +msgid "user name" +msgstr "" + +#: extras/models/change_logging.py:41 +msgid "request ID" +msgstr "" + +#: extras/models/change_logging.py:46 extras/models/staging.py:69 +msgid "action" +msgstr "" + +#: extras/models/change_logging.py:80 +msgid "pre-change data" +msgstr "" + +#: extras/models/change_logging.py:86 +msgid "post-change data" +msgstr "" + +#: extras/models/change_logging.py:96 +msgid "object change" +msgstr "" + +#: extras/models/change_logging.py:97 +msgid "object changes" +msgstr "" + +#: extras/models/configs.py:130 +msgid "config context" +msgstr "" + +#: extras/models/configs.py:131 +msgid "config contexts" +msgstr "" + +#: extras/models/configs.py:149 extras/models/configs.py:205 +msgid "JSON data must be in object form. Example:" +msgstr "" + +#: extras/models/configs.py:169 +msgid "" +"Local config context data takes precedence over source contexts in the final " +"rendered config context" +msgstr "" + +#: extras/models/configs.py:224 +msgid "template code" +msgstr "" + +#: extras/models/configs.py:225 +msgid "Jinja2 template code." +msgstr "" + +#: extras/models/configs.py:228 +msgid "environment parameters" +msgstr "" + +#: extras/models/configs.py:233 +msgid "" +"Any additional parameters to pass when constructing the Jinja2 " +"environment." +msgstr "" + +#: extras/models/configs.py:240 +msgid "config template" +msgstr "" + +#: extras/models/configs.py:241 +msgid "config templates" +msgstr "" + +#: extras/models/customfields.py:66 +msgid "The object(s) to which this field applies." +msgstr "" + +#: extras/models/customfields.py:73 +msgid "The type of data this custom field holds" +msgstr "" + +#: extras/models/customfields.py:80 +msgid "The type of NetBox object this field maps to (for object fields)" +msgstr "" + +#: extras/models/customfields.py:86 +msgid "Internal field name" +msgstr "" + +#: extras/models/customfields.py:90 +msgid "Only alphanumeric characters and underscores are allowed." +msgstr "" + +#: extras/models/customfields.py:95 +msgid "Double underscores are not permitted in custom field names." +msgstr "" + +#: extras/models/customfields.py:106 +msgid "" +"Name of the field as displayed to users (if not provided, 'the field's name " +"will be used)" +msgstr "" + +#: extras/models/customfields.py:110 extras/models/models.py:264 +msgid "group name" +msgstr "" + +#: extras/models/customfields.py:113 +msgid "Custom fields within the same group will be displayed together" +msgstr "" + +#: extras/models/customfields.py:121 +msgid "required" +msgstr "" + +#: extras/models/customfields.py:123 +msgid "" +"If true, this field is required when creating new objects or editing an " +"existing object." +msgstr "" + +#: extras/models/customfields.py:126 +msgid "search weight" +msgstr "" + +#: extras/models/customfields.py:129 +msgid "" +"Weighting for search. Lower values are considered more important. Fields " +"with a search weight of zero will be ignored." +msgstr "" + +#: extras/models/customfields.py:134 +msgid "filter logic" +msgstr "" + +#: extras/models/customfields.py:138 +msgid "" +"Loose matches any instance of a given string; exact matches the entire field." +msgstr "" + +#: extras/models/customfields.py:141 +msgid "default" +msgstr "" + +#: extras/models/customfields.py:145 +msgid "" +"Default value for the field (must be a JSON value). Encapsulate strings with " +"double quotes (e.g. \"Foo\")." +msgstr "" + +#: extras/models/customfields.py:150 +msgid "display weight" +msgstr "" + +#: extras/models/customfields.py:151 +msgid "Fields with higher weights appear lower in a form." +msgstr "" + +#: extras/models/customfields.py:156 +msgid "minimum value" +msgstr "" + +#: extras/models/customfields.py:157 +msgid "Minimum allowed value (for numeric fields)" +msgstr "" + +#: extras/models/customfields.py:162 +msgid "maximum value" +msgstr "" + +#: extras/models/customfields.py:163 +msgid "Maximum allowed value (for numeric fields)" +msgstr "" + +#: extras/models/customfields.py:169 +msgid "validation regex" +msgstr "" + +#: extras/models/customfields.py:171 +#, python-brace-format +msgid "" +"Regular expression to enforce on text field values. Use ^ and $ to force " +"matching of entire string. For example, ^[A-Z]{3}$ will limit " +"values to exactly three uppercase letters." +msgstr "" + +#: extras/models/customfields.py:179 +msgid "choice set" +msgstr "" + +#: extras/models/customfields.py:188 +msgid "Specifies the visibility of custom field in the UI" +msgstr "" + +#: extras/models/customfields.py:192 +msgid "is cloneable" +msgstr "" + +#: extras/models/customfields.py:193 +msgid "Replicate this value when cloning objects" +msgstr "" + +#: extras/models/customfields.py:206 +msgid "custom field" +msgstr "" + +#: extras/models/customfields.py:207 +msgid "custom fields" +msgstr "" + +#: extras/models/customfields.py:290 +#, python-brace-format +msgid "Invalid default value \"{value}\": {error}" +msgstr "" + +#: extras/models/customfields.py:297 +msgid "A minimum value may be set only for numeric fields" +msgstr "" + +#: extras/models/customfields.py:299 +msgid "A maximum value may be set only for numeric fields" +msgstr "" + +#: extras/models/customfields.py:309 +msgid "Regular expression validation is supported only for text and URL fields" +msgstr "" + +#: extras/models/customfields.py:319 +msgid "Selection fields must specify a set of choices." +msgstr "" + +#: extras/models/customfields.py:323 +msgid "Choices may be set only on selection fields." +msgstr "" + +#: extras/models/customfields.py:330 +msgid "Object fields must define an object type." +msgstr "" + +#: extras/models/customfields.py:335 +#, python-brace-format +msgid "{type} fields may not define an object type." +msgstr "" + +#: extras/models/customfields.py:415 +msgid "True" +msgstr "" + +#: extras/models/customfields.py:416 +msgid "False" +msgstr "" + +#: extras/models/customfields.py:498 +#, python-brace-format +msgid "Values must match this regex: {regex}" +msgstr "" + +#: extras/models/customfields.py:513 +msgid "Field is set to read-only." +msgstr "" + +#: extras/models/customfields.py:595 +msgid "Value must be a string." +msgstr "" + +#: extras/models/customfields.py:597 +#, python-brace-format +msgid "Value must match regex '{regex}'" +msgstr "" + +#: extras/models/customfields.py:602 +msgid "Value must be an integer." +msgstr "" + +#: extras/models/customfields.py:605 extras/models/customfields.py:620 +#, python-brace-format +msgid "Value must be at least {minimum}" +msgstr "" + +#: extras/models/customfields.py:609 extras/models/customfields.py:624 +#, python-brace-format +msgid "Value must not exceed {maximum}" +msgstr "" + +#: extras/models/customfields.py:617 +msgid "Value must be a decimal." +msgstr "" + +#: extras/models/customfields.py:629 +msgid "Value must be true or false." +msgstr "" + +#: extras/models/customfields.py:637 +msgid "Date values must be in ISO 8601 format (YYYY-MM-DD)." +msgstr "" + +#: extras/models/customfields.py:646 +msgid "Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS)." +msgstr "" + +#: extras/models/customfields.py:653 +#, python-brace-format +msgid "Invalid choice ({value}) for choice set {choiceset}." +msgstr "" + +#: extras/models/customfields.py:663 +#, python-brace-format +msgid "Invalid choice(s) ({value}) for choice set {choiceset}." +msgstr "" + +#: extras/models/customfields.py:672 +#, python-brace-format +msgid "Value must be an object ID, not {type}" +msgstr "" + +#: extras/models/customfields.py:678 +#, python-brace-format +msgid "Value must be a list of object IDs, not {type}" +msgstr "" + +#: extras/models/customfields.py:682 +#, python-brace-format +msgid "Found invalid object ID: {id}" +msgstr "" + +#: extras/models/customfields.py:685 +msgid "Required field cannot be empty." +msgstr "" + +#: extras/models/customfields.py:704 +msgid "Base set of predefined choices (optional)" +msgstr "" + +#: extras/models/customfields.py:716 +msgid "Choices are automatically ordered alphabetically" +msgstr "" + +#: extras/models/customfields.py:723 +msgid "custom field choice set" +msgstr "" + +#: extras/models/customfields.py:724 +msgid "custom field choice sets" +msgstr "" + +#: extras/models/customfields.py:760 +msgid "Must define base or extra choices." +msgstr "" + +#: extras/models/dashboard.py:19 +msgid "layout" +msgstr "" + +#: extras/models/dashboard.py:23 +msgid "config" +msgstr "" + +#: extras/models/dashboard.py:28 +msgid "dashboard" +msgstr "" + +#: extras/models/dashboard.py:29 +msgid "dashboards" +msgstr "" + +#: extras/models/models.py:50 +msgid "object types" +msgstr "" + +#: extras/models/models.py:52 +msgid "The object(s) to which this Webhook applies." +msgstr "" + +#: extras/models/models.py:60 +msgid "on create" +msgstr "" + +#: extras/models/models.py:62 +msgid "Triggers when a matching object is created." +msgstr "" + +#: extras/models/models.py:65 +msgid "on update" +msgstr "" + +#: extras/models/models.py:67 +msgid "Triggers when a matching object is updated." +msgstr "" + +#: extras/models/models.py:70 +msgid "on delete" +msgstr "" + +#: extras/models/models.py:72 +msgid "Triggers when a matching object is deleted." +msgstr "" + +#: extras/models/models.py:75 +msgid "on job start" +msgstr "" + +#: extras/models/models.py:77 +msgid "Triggers when a job for a matching object is started." +msgstr "" + +#: extras/models/models.py:80 +msgid "on job end" +msgstr "" + +#: extras/models/models.py:82 +msgid "Triggers when a job for a matching object terminates." +msgstr "" + +#: extras/models/models.py:88 +msgid "" +"This URL will be called using the HTTP method defined when the webhook is " +"called. Jinja2 template processing is supported with the same context as the " +"request body." +msgstr "" + +#: extras/models/models.py:105 +msgid "HTTP content type" +msgstr "" + +#: extras/models/models.py:107 +msgid "" +"The complete list of official content types is available here." +msgstr "" + +#: extras/models/models.py:112 +msgid "additional headers" +msgstr "" + +#: extras/models/models.py:115 +msgid "" +"User-supplied HTTP headers to be sent with the request in addition to the " +"HTTP content type. Headers should be defined in the format Name: " +"Value. Jinja2 template processing is supported with the same context " +"as the request body (below)." +msgstr "" + +#: extras/models/models.py:121 +msgid "body template" +msgstr "" + +#: extras/models/models.py:124 +msgid "" +"Jinja2 template for a custom request body. If blank, a JSON object " +"representing the change will be included. Available context data includes: " +"event, model, timestamp, " +"username, request_id, and data." +msgstr "" + +#: extras/models/models.py:130 +msgid "secret" +msgstr "" + +#: extras/models/models.py:134 +msgid "" +"When provided, the request will include a X-Hook-Signature " +"header containing a HMAC hex digest of the payload body using the secret as " +"the key. The secret is not transmitted in the request." +msgstr "" + +#: extras/models/models.py:139 +msgid "conditions" +msgstr "" + +#: extras/models/models.py:142 +msgid "" +"A set of conditions which determine whether the webhook will be generated." +msgstr "" + +#: extras/models/models.py:147 +msgid "Enable SSL certificate verification. Disable with caution!" +msgstr "" + +#: extras/models/models.py:153 templates/extras/webhook.html:91 +msgid "CA File Path" +msgstr "" + +#: extras/models/models.py:155 +msgid "" +"The specific CA certificate file to use for SSL verification. Leave blank to " +"use the system defaults." +msgstr "" + +#: extras/models/models.py:167 +msgid "webhook" +msgstr "" + +#: extras/models/models.py:168 +msgid "webhooks" +msgstr "" + +#: extras/models/models.py:188 +msgid "" +"At least one event type must be selected: create, update, delete, job_start, " +"and/or job_end." +msgstr "" + +#: extras/models/models.py:200 +msgid "Do not specify a CA certificate file if SSL verification is disabled." +msgstr "" + +#: extras/models/models.py:240 +msgid "The object type(s) to which this link applies." +msgstr "" + +#: extras/models/models.py:252 +msgid "link text" +msgstr "" + +#: extras/models/models.py:253 +msgid "Jinja2 template code for link text" +msgstr "" + +#: extras/models/models.py:256 +msgid "link URL" +msgstr "" + +#: extras/models/models.py:257 +msgid "Jinja2 template code for link URL" +msgstr "" + +#: extras/models/models.py:267 +msgid "Links with the same group will appear as a dropdown menu" +msgstr "" + +#: extras/models/models.py:270 +msgid "button class" +msgstr "" + +#: extras/models/models.py:274 +msgid "" +"The class of the first link in a group will be used for the dropdown button" +msgstr "" + +#: extras/models/models.py:277 +msgid "new window" +msgstr "" + +#: extras/models/models.py:279 +msgid "Force link to open in a new window" +msgstr "" + +#: extras/models/models.py:288 +msgid "custom link" +msgstr "" + +#: extras/models/models.py:289 +msgid "custom links" +msgstr "" + +#: extras/models/models.py:336 +msgid "The object type(s) to which this template applies." +msgstr "" + +#: extras/models/models.py:349 +msgid "" +"Jinja2 template code. The list of objects being exported is passed as a " +"context variable named queryset." +msgstr "" + +#: extras/models/models.py:357 +msgid "Defaults to text/plain; charset=utf-8" +msgstr "" + +#: extras/models/models.py:360 +msgid "file extension" +msgstr "" + +#: extras/models/models.py:363 +msgid "Extension to append to the rendered filename" +msgstr "" + +#: extras/models/models.py:366 +msgid "as attachment" +msgstr "" + +#: extras/models/models.py:368 +msgid "Download file as attachment" +msgstr "" + +#: extras/models/models.py:377 +msgid "export template" +msgstr "" + +#: extras/models/models.py:378 +msgid "export templates" +msgstr "" + +#: extras/models/models.py:395 +#, python-brace-format +msgid "\"{name}\" is a reserved name. Please choose a different name." +msgstr "" + +#: extras/models/models.py:445 +msgid "The object type(s) to which this filter applies." +msgstr "" + +#: extras/models/models.py:477 +msgid "shared" +msgstr "" + +#: extras/models/models.py:490 +msgid "saved filter" +msgstr "" + +#: extras/models/models.py:491 +msgid "saved filters" +msgstr "" + +#: extras/models/models.py:509 +msgid "Filter parameters must be stored as a dictionary of keyword arguments." +msgstr "" + +#: extras/models/models.py:537 +msgid "image height" +msgstr "" + +#: extras/models/models.py:540 +msgid "image width" +msgstr "" + +#: extras/models/models.py:554 +msgid "image attachment" +msgstr "" + +#: extras/models/models.py:555 +msgid "image attachments" +msgstr "" + +#: extras/models/models.py:623 +msgid "kind" +msgstr "" + +#: extras/models/models.py:634 +msgid "journal entry" +msgstr "" + +#: extras/models/models.py:635 +msgid "journal entries" +msgstr "" + +#: extras/models/models.py:651 +#, python-brace-format +msgid "Journaling is not supported for this object type ({type})." +msgstr "" + +#: extras/models/models.py:690 +msgid "bookmark" +msgstr "" + +#: extras/models/models.py:691 +msgid "bookmarks" +msgstr "" + +#: extras/models/models.py:708 +msgid "comment" +msgstr "" + +#: extras/models/models.py:715 +msgid "configuration data" +msgstr "" + +#: extras/models/models.py:722 +msgid "config revision" +msgstr "" + +#: extras/models/models.py:723 +msgid "config revisions" +msgstr "" + +#: extras/models/models.py:727 +msgid "Default configuration" +msgstr "" + +#: extras/models/models.py:729 +msgid "Current configuration" +msgstr "" + +#: extras/models/models.py:730 +#, python-brace-format +msgid "Config revision #{id}" +msgstr "" + +#: extras/models/reports.py:46 +msgid "report module" +msgstr "" + +#: extras/models/reports.py:47 +msgid "report modules" +msgstr "" + +#: extras/models/scripts.py:46 +msgid "script module" +msgstr "" + +#: extras/models/scripts.py:47 +msgid "script modules" +msgstr "" + +#: extras/models/search.py:22 +msgid "timestamp" +msgstr "" + +#: extras/models/search.py:37 +msgid "field" +msgstr "" + +#: extras/models/search.py:45 +msgid "value" +msgstr "" + +#: extras/models/search.py:54 +msgid "cached value" +msgstr "" + +#: extras/models/search.py:55 +msgid "cached values" +msgstr "" + +#: extras/models/staging.py:44 +msgid "branch" +msgstr "" + +#: extras/models/staging.py:45 +msgid "branches" +msgstr "" + +#: extras/models/staging.py:94 +msgid "staged change" +msgstr "" + +#: extras/models/staging.py:95 +msgid "staged changes" +msgstr "" + +#: extras/models/tags.py:44 +msgid "The object type(s) to which this this tag can be applied." +msgstr "" + +#: extras/models/tags.py:53 +msgid "tag" +msgstr "" + +#: extras/models/tags.py:54 +msgid "tags" +msgstr "" + +#: extras/models/tags.py:80 +msgid "tagged item" +msgstr "" + +#: extras/models/tags.py:81 +msgid "tagged items" +msgstr "" + +#: extras/tables/tables.py:48 users/forms/filtersets.py:47 users/tables.py:39 +msgid "Is Active" +msgstr "" + +#: extras/tables/tables.py:69 extras/tables/tables.py:141 +#: extras/tables/tables.py:165 extras/tables/tables.py:230 +#: extras/tables/tables.py:277 +msgid "Content Types" +msgstr "" + +#: extras/tables/tables.py:75 templates/extras/customfield.html:82 +msgid "UI Visibility" +msgstr "" + +#: extras/tables/tables.py:82 templates/extras/customfield.html:48 +msgid "Choice Set" +msgstr "" + +#: extras/tables/tables.py:90 +msgid "Is Cloneable" +msgstr "" + +#: extras/tables/tables.py:120 +msgid "Count" +msgstr "" + +#: extras/tables/tables.py:123 +msgid "Order Alphabetically" +msgstr "" + +#: extras/tables/tables.py:147 templates/extras/customlink.html:34 +msgid "New Window" +msgstr "" + +#: extras/tables/tables.py:168 +msgid "As Attachment" +msgstr "" + +#: extras/tables/tables.py:175 extras/tables/tables.py:367 +#: extras/tables/tables.py:402 templates/core/datafile.html:32 +#: templates/dcim/device/render_config.html:23 +#: templates/extras/configcontext.html:40 +#: templates/extras/configtemplate.html:32 +#: templates/extras/exporttemplate.html:51 +#: templates/generic/bulk_import.html:30 +#: templates/virtualization/virtualmachine/render_config.html:23 +msgid "Data File" +msgstr "" + +#: extras/tables/tables.py:180 extras/tables/tables.py:379 +#: extras/tables/tables.py:407 +msgid "Synced" +msgstr "" + +#: extras/tables/tables.py:200 +msgid "Content Type" +msgstr "" + +#: extras/tables/tables.py:207 +msgid "Image" +msgstr "" + +#: extras/tables/tables.py:212 +msgid "Size (Bytes)" +msgstr "" + +#: extras/tables/tables.py:255 extras/tables/tables.py:326 +#: templates/extras/customfield.html:92 +#: templates/users/objectpermission.html:68 users/tables.py:83 +msgid "Object Types" +msgstr "" + +#: extras/tables/tables.py:292 +msgid "Job Start" +msgstr "" + +#: extras/tables/tables.py:295 +msgid "Job End" +msgstr "" + +#: extras/tables/tables.py:298 +msgid "SSL Validation" +msgstr "" + +#: extras/tables/tables.py:436 templates/account/profile.html:20 +#: templates/users/user.html:22 +msgid "Full Name" +msgstr "" + +#: extras/tables/tables.py:453 templates/extras/objectchange.html:72 +msgid "Request ID" +msgstr "" + +#: extras/tables/tables.py:490 +msgid "Comments (Short)" +msgstr "" + +#: extras/views.py:836 +msgid "Your dashboard has been reset." +msgstr "" + +#: ipam/api/field_serializers.py:17 +msgid "Enter a valid IPv4 or IPv6 address with optional mask." +msgstr "" + +#: ipam/api/field_serializers.py:24 +#, python-brace-format +msgid "Invalid IP address format: {data}" +msgstr "" + +#: ipam/api/field_serializers.py:37 +msgid "Enter a valid IPv4 or IPv6 prefix and mask in CIDR notation." +msgstr "" + +#: ipam/api/field_serializers.py:44 +#, python-brace-format +msgid "Invalid IP prefix format: {data}" +msgstr "" + +#: ipam/choices.py:30 +msgid "Container" +msgstr "" + +#: ipam/choices.py:72 +msgid "DHCP" +msgstr "" + +#: ipam/choices.py:73 +msgid "SLAAC" +msgstr "" + +#: ipam/choices.py:89 +msgid "Loopback" +msgstr "" + +#: ipam/choices.py:90 tenancy/choices.py:18 +msgid "Secondary" +msgstr "" + +#: ipam/choices.py:91 +msgid "Anycast" +msgstr "" + +#: ipam/choices.py:115 +msgid "Standard" +msgstr "" + +#: ipam/choices.py:120 +msgid "CheckPoint" +msgstr "" + +#: ipam/choices.py:123 +msgid "Cisco" +msgstr "" + +#: ipam/choices.py:137 +msgid "Plaintext" +msgstr "" + +#: ipam/filtersets.py:47 ipam/filtersets.py:1068 +msgid "Import target" +msgstr "" + +#: ipam/filtersets.py:53 ipam/filtersets.py:1074 +msgid "Import target (name)" +msgstr "" + +#: ipam/filtersets.py:58 ipam/filtersets.py:1079 +msgid "Export target" +msgstr "" + +#: ipam/filtersets.py:64 ipam/filtersets.py:1085 +msgid "Export target (name)" +msgstr "" + +#: ipam/filtersets.py:85 +msgid "Importing VRF" +msgstr "" + +#: ipam/filtersets.py:91 +msgid "Import VRF (RD)" +msgstr "" + +#: ipam/filtersets.py:96 +msgid "Exporting VRF" +msgstr "" + +#: ipam/filtersets.py:102 +msgid "Export VRF (RD)" +msgstr "" + +#: ipam/filtersets.py:132 ipam/filtersets.py:247 ipam/forms/model_forms.py:231 +#: ipam/tables/ip.py:211 templates/ipam/prefix.html:11 +msgid "Prefix" +msgstr "" + +#: ipam/filtersets.py:136 ipam/filtersets.py:175 ipam/filtersets.py:198 +msgid "RIR (ID)" +msgstr "" + +#: ipam/filtersets.py:142 ipam/filtersets.py:181 ipam/filtersets.py:204 +msgid "RIR (slug)" +msgstr "" + +#: ipam/filtersets.py:251 +msgid "Within prefix" +msgstr "" + +#: ipam/filtersets.py:255 +msgid "Within and including prefix" +msgstr "" + +#: ipam/filtersets.py:259 +msgid "Prefixes which contain this prefix or IP" +msgstr "" + +#: ipam/filtersets.py:338 ipam/filtersets.py:1191 +msgid "VLAN (ID)" +msgstr "" + +#: ipam/filtersets.py:342 ipam/filtersets.py:1186 +msgid "VLAN number (1-4094)" +msgstr "" + +#: ipam/filtersets.py:436 ipam/filtersets.py:440 ipam/filtersets.py:532 +#: ipam/forms/model_forms.py:446 templates/tenancy/contact.html:54 +#: tenancy/forms/bulk_edit.py:112 +msgid "Address" +msgstr "" + +#: ipam/filtersets.py:444 +msgid "Ranges which contain this prefix or IP" +msgstr "" + +#: ipam/filtersets.py:472 ipam/filtersets.py:528 +msgid "Parent prefix" +msgstr "" + +#: ipam/filtersets.py:536 ipam/forms/bulk_edit.py:328 +#: ipam/forms/filtersets.py:195 ipam/forms/filtersets.py:320 +msgid "Mask length" +msgstr "" + +#: ipam/filtersets.py:572 ipam/filtersets.py:807 ipam/filtersets.py:1026 +#: ipam/filtersets.py:1149 +msgid "Virtual machine (name)" +msgstr "" + +#: ipam/filtersets.py:577 ipam/filtersets.py:812 ipam/filtersets.py:1020 +#: ipam/filtersets.py:1154 virtualization/filtersets.py:273 +msgid "Virtual machine (ID)" +msgstr "" + +#: ipam/filtersets.py:583 ipam/filtersets.py:1160 +msgid "Interface (name)" +msgstr "" + +#: ipam/filtersets.py:588 ipam/filtersets.py:1165 +msgid "Interface (ID)" +msgstr "" + +#: ipam/filtersets.py:594 ipam/filtersets.py:1171 +msgid "VM interface (name)" +msgstr "" + +#: ipam/filtersets.py:599 +msgid "VM interface (ID)" +msgstr "" + +#: ipam/filtersets.py:604 +msgid "FHRP group (ID)" +msgstr "" + +#: ipam/filtersets.py:608 +msgid "Is assigned to an interface" +msgstr "" + +#: ipam/filtersets.py:612 +msgid "Is assigned" +msgstr "" + +#: ipam/filtersets.py:1031 +msgid "IP address (ID)" +msgstr "" + +#: ipam/filtersets.py:1037 ipam/models/ip.py:786 +msgid "IP address" +msgstr "" + +#: ipam/filtersets.py:1112 +msgid "L2VPN (slug)" +msgstr "" + +#: ipam/filtersets.py:1176 +msgid "VM Interface (ID)" +msgstr "" + +#: ipam/filtersets.py:1182 +msgid "VLAN (name)" +msgstr "" + +#: ipam/forms/bulk_create.py:14 +msgid "Address pattern" +msgstr "" + +#: ipam/forms/bulk_edit.py:87 +msgid "Is private" +msgstr "" + +#: ipam/forms/bulk_edit.py:108 ipam/forms/bulk_edit.py:137 +#: ipam/forms/bulk_edit.py:162 ipam/forms/bulk_import.py:91 +#: ipam/forms/bulk_import.py:111 ipam/forms/bulk_import.py:131 +#: ipam/forms/filtersets.py:113 ipam/forms/filtersets.py:128 +#: ipam/forms/filtersets.py:151 ipam/forms/model_forms.py:95 +#: ipam/forms/model_forms.py:110 ipam/forms/model_forms.py:132 +#: ipam/forms/model_forms.py:150 ipam/models/asns.py:31 ipam/models/asns.py:103 +#: ipam/models/ip.py:70 ipam/models/ip.py:89 ipam/tables/asn.py:20 +#: ipam/tables/asn.py:45 templates/ipam/aggregate.html:19 +#: templates/ipam/asn.html:28 templates/ipam/asnrange.html:20 +#: templates/ipam/rir.html:20 +msgid "RIR" +msgstr "" + +#: ipam/forms/bulk_edit.py:170 +msgid "Date added" +msgstr "" + +#: ipam/forms/bulk_edit.py:231 +msgid "Prefix length" +msgstr "" + +#: ipam/forms/bulk_edit.py:254 ipam/forms/filtersets.py:240 +#: templates/ipam/prefix.html:86 +msgid "Is a pool" +msgstr "" + +#: ipam/forms/bulk_edit.py:259 ipam/forms/bulk_edit.py:303 +#: ipam/models/ip.py:271 ipam/models/ip.py:538 +#, python-format +msgid "Treat as 100% utilized" +msgstr "" + +#: ipam/forms/bulk_edit.py:351 ipam/models/ip.py:771 +msgid "DNS name" +msgstr "" + +#: ipam/forms/bulk_edit.py:372 ipam/forms/bulk_edit.py:571 +#: ipam/forms/bulk_import.py:396 ipam/forms/bulk_import.py:480 +#: ipam/forms/bulk_import.py:506 ipam/forms/filtersets.py:379 +#: ipam/forms/filtersets.py:513 templates/ipam/fhrpgroup.html:23 +#: templates/ipam/inc/panels/fhrp_groups.html:11 templates/ipam/service.html:35 +#: templates/ipam/servicetemplate.html:20 +msgid "Protocol" +msgstr "" + +#: ipam/forms/bulk_edit.py:379 ipam/forms/filtersets.py:386 +#: ipam/tables/fhrp.py:22 templates/ipam/fhrpgroup.html:27 +msgid "Group ID" +msgstr "" + +#: ipam/forms/bulk_edit.py:384 ipam/forms/filtersets.py:391 +#: wireless/forms/bulk_edit.py:67 wireless/forms/bulk_edit.py:114 +#: wireless/forms/bulk_import.py:62 wireless/forms/bulk_import.py:65 +#: wireless/forms/bulk_import.py:104 wireless/forms/bulk_import.py:107 +#: wireless/forms/filtersets.py:53 wireless/forms/filtersets.py:87 +msgid "Authentication type" +msgstr "" + +#: ipam/forms/bulk_edit.py:389 ipam/forms/filtersets.py:395 +msgid "Authentication key" +msgstr "" + +#: ipam/forms/bulk_edit.py:406 ipam/forms/filtersets.py:372 +#: ipam/forms/model_forms.py:457 netbox/navigation/menu.py:356 +#: templates/ipam/fhrpgroup.html:51 +#: templates/wireless/inc/authentication_attrs.html:5 +#: wireless/forms/bulk_edit.py:90 wireless/forms/bulk_edit.py:137 +#: wireless/forms/filtersets.py:35 wireless/forms/filtersets.py:75 +#: wireless/forms/model_forms.py:56 wireless/forms/model_forms.py:161 +msgid "Authentication" +msgstr "" + +#: ipam/forms/bulk_edit.py:416 +msgid "Minimum child VLAN VID" +msgstr "" + +#: ipam/forms/bulk_edit.py:422 +msgid "Maximum child VLAN VID" +msgstr "" + +#: ipam/forms/bulk_edit.py:430 ipam/forms/model_forms.py:529 +msgid "Scope type" +msgstr "" + +#: ipam/forms/bulk_edit.py:491 ipam/forms/model_forms.py:602 +#: ipam/tables/vlans.py:71 templates/ipam/vlangroup.html:39 +msgid "Scope" +msgstr "" + +#: ipam/forms/bulk_edit.py:562 +msgid "Site & Group" +msgstr "" + +#: ipam/forms/bulk_edit.py:576 ipam/forms/model_forms.py:665 +#: ipam/forms/model_forms.py:699 ipam/tables/services.py:19 +#: ipam/tables/services.py:49 templates/ipam/service.html:39 +#: templates/ipam/servicetemplate.html:24 +msgid "Ports" +msgstr "" + +#: ipam/forms/bulk_import.py:50 +msgid "Import route targets" +msgstr "" + +#: ipam/forms/bulk_import.py:56 +msgid "Export route targets" +msgstr "" + +#: ipam/forms/bulk_import.py:94 ipam/forms/bulk_import.py:114 +#: ipam/forms/bulk_import.py:134 +msgid "Assigned RIR" +msgstr "" + +#: ipam/forms/bulk_import.py:184 +msgid "VLAN's group (if any)" +msgstr "" + +#: ipam/forms/bulk_import.py:187 ipam/forms/bulk_import.py:564 +#: ipam/forms/filtersets.py:603 ipam/forms/model_forms.py:221 +#: ipam/forms/model_forms.py:804 ipam/models/vlans.py:213 ipam/tables/ip.py:254 +#: templates/ipam/l2vpntermination_edit.html:17 templates/ipam/prefix.html:61 +#: templates/ipam/vlan.html:12 templates/ipam/vlan/base.html:6 +#: templates/ipam/vlan_edit.html:10 templates/wireless/wirelesslan.html:31 +#: wireless/forms/bulk_edit.py:54 wireless/forms/bulk_import.py:48 +#: wireless/forms/model_forms.py:49 wireless/models.py:101 +msgid "VLAN" +msgstr "" + +#: ipam/forms/bulk_import.py:310 +msgid "Parent device of assigned interface (if any)" +msgstr "" + +#: ipam/forms/bulk_import.py:313 ipam/forms/bulk_import.py:499 +#: ipam/forms/bulk_import.py:550 ipam/forms/model_forms.py:693 +#: virtualization/filtersets.py:279 virtualization/forms/bulk_edit.py:197 +#: virtualization/forms/bulk_import.py:145 +#: virtualization/forms/filtersets.py:200 +#: virtualization/forms/model_forms.py:280 +msgid "Virtual machine" +msgstr "" + +#: ipam/forms/bulk_import.py:317 +msgid "Parent VM of assigned interface (if any)" +msgstr "" + +#: ipam/forms/bulk_import.py:324 +msgid "Assigned interface" +msgstr "" + +#: ipam/forms/bulk_import.py:327 +msgid "Is primary" +msgstr "" + +#: ipam/forms/bulk_import.py:328 +msgid "Make this the primary IP for the assigned device" +msgstr "" + +#: ipam/forms/bulk_import.py:367 +msgid "No device or virtual machine specified; cannot set as primary IP" +msgstr "" + +#: ipam/forms/bulk_import.py:371 +msgid "No interface specified; cannot set as primary IP" +msgstr "" + +#: ipam/forms/bulk_import.py:400 +msgid "Auth type" +msgstr "" + +#: ipam/forms/bulk_import.py:415 +msgid "Scope type (app & model)" +msgstr "" + +#: ipam/forms/bulk_import.py:421 +#, python-brace-format +msgid "Minimum child VLAN VID (default: {minimum})" +msgstr "" + +#: ipam/forms/bulk_import.py:427 +#, python-brace-format +msgid "Maximum child VLAN VID (default: {maximum})" +msgstr "" + +#: ipam/forms/bulk_import.py:451 +msgid "Assigned VLAN group" +msgstr "" + +#: ipam/forms/bulk_import.py:482 ipam/forms/bulk_import.py:508 +msgid "IP protocol" +msgstr "" + +#: ipam/forms/bulk_import.py:496 +msgid "Required if not assigned to a VM" +msgstr "" + +#: ipam/forms/bulk_import.py:503 +msgid "Required if not assigned to a device" +msgstr "" + +#: ipam/forms/bulk_import.py:526 +msgid "L2VPN type" +msgstr "" + +#: ipam/forms/bulk_import.py:547 +msgid "Parent device (for interface)" +msgstr "" + +#: ipam/forms/bulk_import.py:554 +msgid "Parent virtual machine (for interface)" +msgstr "" + +#: ipam/forms/bulk_import.py:561 +msgid "Assigned interface (device or VM)" +msgstr "" + +#: ipam/forms/bulk_import.py:594 +msgid "Cannot import device and VM interface terminations simultaneously." +msgstr "" + +#: ipam/forms/bulk_import.py:596 +msgid "Each termination must specify either an interface or a VLAN." +msgstr "" + +#: ipam/forms/bulk_import.py:598 +msgid "Cannot assign both an interface and a VLAN." +msgstr "" + +#: ipam/forms/filtersets.py:50 ipam/forms/model_forms.py:62 +#: ipam/forms/model_forms.py:780 netbox/navigation/menu.py:177 +msgid "Route Targets" +msgstr "" + +#: ipam/forms/filtersets.py:56 ipam/forms/filtersets.py:544 +#: ipam/forms/model_forms.py:49 ipam/forms/model_forms.py:767 +msgid "Import targets" +msgstr "" + +#: ipam/forms/filtersets.py:61 ipam/forms/filtersets.py:549 +#: ipam/forms/model_forms.py:54 ipam/forms/model_forms.py:772 +msgid "Export targets" +msgstr "" + +#: ipam/forms/filtersets.py:76 +msgid "Imported by VRF" +msgstr "" + +#: ipam/forms/filtersets.py:81 +msgid "Exported by VRF" +msgstr "" + +#: ipam/forms/filtersets.py:90 ipam/tables/ip.py:89 templates/ipam/rir.html:33 +msgid "Private" +msgstr "" + +#: ipam/forms/filtersets.py:108 ipam/forms/filtersets.py:190 +#: ipam/forms/filtersets.py:265 ipam/forms/filtersets.py:315 +msgid "Address family" +msgstr "" + +#: ipam/forms/filtersets.py:122 templates/ipam/asnrange.html:26 +msgid "Range" +msgstr "" + +#: ipam/forms/filtersets.py:131 +msgid "Start" +msgstr "" + +#: ipam/forms/filtersets.py:135 +msgid "End" +msgstr "" + +#: ipam/forms/filtersets.py:185 +msgid "Search within" +msgstr "" + +#: ipam/forms/filtersets.py:206 ipam/forms/filtersets.py:331 +msgid "Present in VRF" +msgstr "" + +#: ipam/forms/filtersets.py:247 ipam/forms/filtersets.py:286 +#, python-format +msgid "Marked as 100% utilized" +msgstr "" + +#: ipam/forms/filtersets.py:301 +msgid "Device/VM" +msgstr "" + +#: ipam/forms/filtersets.py:336 +msgid "Assigned Device" +msgstr "" + +#: ipam/forms/filtersets.py:341 +msgid "Assigned VM" +msgstr "" + +#: ipam/forms/filtersets.py:355 +msgid "Assigned to an interface" +msgstr "" + +#: ipam/forms/filtersets.py:362 templates/ipam/ipaddress.html:54 +msgid "DNS Name" +msgstr "" + +#: ipam/forms/filtersets.py:404 ipam/forms/filtersets.py:496 +#: ipam/models/vlans.py:154 templates/ipam/vlan.html:34 +msgid "VLAN ID" +msgstr "" + +#: ipam/forms/filtersets.py:436 +msgid "Minimum VID" +msgstr "" + +#: ipam/forms/filtersets.py:442 +msgid "Maximum VID" +msgstr "" + +#: ipam/forms/filtersets.py:518 +msgid "Port" +msgstr "" + +#: ipam/forms/filtersets.py:558 ipam/tables/ip.py:424 +#: templates/ipam/l2vpntermination.html:19 +msgid "Assigned Object" +msgstr "" + +#: ipam/forms/filtersets.py:570 +msgid "Assigned Object Type" +msgstr "" + +#: ipam/forms/filtersets.py:612 ipam/tables/vlans.py:191 +#: templates/ipam/ipaddress_edit.html:47 +#: templates/ipam/l2vpntermination_edit.html:27 +#: templates/ipam/service_create.html:22 templates/ipam/service_edit.html:21 +#: templates/virtualization/virtualmachine.html:13 +#: templates/virtualization/vminterface.html:24 +#: virtualization/forms/filtersets.py:186 +#: virtualization/forms/model_forms.py:221 +#: virtualization/tables/virtualmachines.py:110 +msgid "Virtual Machine" +msgstr "" + +#: ipam/forms/model_forms.py:115 ipam/tables/ip.py:116 +#: templates/ipam/aggregate.html:11 templates/ipam/prefix.html:38 +msgid "Aggregate" +msgstr "" + +#: ipam/forms/model_forms.py:136 templates/ipam/asnrange.html:12 +msgid "ASN Range" +msgstr "" + +#: ipam/forms/model_forms.py:232 +msgid "Site/VLAN Assignment" +msgstr "" + +#: ipam/forms/model_forms.py:258 templates/ipam/iprange.html:11 +msgid "IP Range" +msgstr "" + +#: ipam/forms/model_forms.py:287 ipam/forms/model_forms.py:456 +#: templates/ipam/fhrpgroup.html:19 templates/ipam/ipaddress_edit.html:52 +msgid "FHRP Group" +msgstr "" + +#: ipam/forms/model_forms.py:302 +msgid "Make this the primary IP for the device/VM" +msgstr "" + +#: ipam/forms/model_forms.py:353 +msgid "An IP address can only be assigned to a single object." +msgstr "" + +#: ipam/forms/model_forms.py:359 ipam/models/ip.py:877 +msgid "" +"Cannot reassign IP address while it is designated as the primary IP for the " +"parent object" +msgstr "" + +#: ipam/forms/model_forms.py:369 +msgid "" +"Only IP addresses assigned to an interface can be designated as primary IPs." +msgstr "" + +#: ipam/forms/model_forms.py:375 +#, python-brace-format +msgid "{ip} is a network ID, which may not be assigned to an interface." +msgstr "" + +#: ipam/forms/model_forms.py:381 +#, python-brace-format +msgid "{ip} is a broadcast address, which may not be assigned to an interface." +msgstr "" + +#: ipam/forms/model_forms.py:458 +msgid "Virtual IP Address" +msgstr "" + +#: ipam/forms/model_forms.py:600 ipam/forms/model_forms.py:639 +#: ipam/tables/ip.py:250 templates/ipam/vlan_edit.html:37 +#: templates/ipam/vlangroup.html:27 +msgid "VLAN Group" +msgstr "" + +#: ipam/forms/model_forms.py:601 +msgid "Child VLANs" +msgstr "" + +#: ipam/forms/model_forms.py:670 ipam/forms/model_forms.py:704 +msgid "" +"Comma-separated list of one or more port numbers. A range may be specified " +"using a hyphen." +msgstr "" + +#: ipam/forms/model_forms.py:675 templates/ipam/servicetemplate.html:12 +msgid "Service Template" +msgstr "" + +#: ipam/forms/model_forms.py:726 +msgid "Service template" +msgstr "" + +#: ipam/forms/model_forms.py:846 +msgid "A termination must specify an interface or VLAN." +msgstr "" + +#: ipam/forms/model_forms.py:848 +msgid "" +"A termination can only have one terminating object (an interface or VLAN)." +msgstr "" + +#: ipam/models/asns.py:34 +msgid "start" +msgstr "" + +#: ipam/models/asns.py:51 +msgid "ASN range" +msgstr "" + +#: ipam/models/asns.py:52 +msgid "ASN ranges" +msgstr "" + +#: ipam/models/asns.py:72 +#, python-brace-format +msgid "Starting ASN ({start}) must be lower than ending ASN ({end})." +msgstr "" + +#: ipam/models/asns.py:104 +msgid "Regional Internet Registry responsible for this AS number space" +msgstr "" + +#: ipam/models/asns.py:109 +msgid "16- or 32-bit autonomous system number" +msgstr "" + +#: ipam/models/fhrp.py:23 +msgid "group ID" +msgstr "" + +#: ipam/models/fhrp.py:31 ipam/models/services.py:22 +msgid "protocol" +msgstr "" + +#: ipam/models/fhrp.py:39 wireless/models.py:27 +msgid "authentication type" +msgstr "" + +#: ipam/models/fhrp.py:44 +msgid "authentication key" +msgstr "" + +#: ipam/models/fhrp.py:57 +msgid "FHRP group" +msgstr "" + +#: ipam/models/fhrp.py:58 +msgid "FHRP groups" +msgstr "" + +#: ipam/models/fhrp.py:94 tenancy/models/contacts.py:133 +msgid "priority" +msgstr "" + +#: ipam/models/fhrp.py:111 +msgid "FHRP group assignment" +msgstr "" + +#: ipam/models/fhrp.py:112 +msgid "FHRP group assignments" +msgstr "" + +#: ipam/models/ip.py:64 +msgid "private" +msgstr "" + +#: ipam/models/ip.py:65 +msgid "IP space managed by this RIR is considered private" +msgstr "" + +#: ipam/models/ip.py:71 netbox/navigation/menu.py:170 +msgid "RIRs" +msgstr "" + +#: ipam/models/ip.py:83 +msgid "IPv4 or IPv6 network" +msgstr "" + +#: ipam/models/ip.py:90 +msgid "Regional Internet Registry responsible for this IP space" +msgstr "" + +#: ipam/models/ip.py:100 +msgid "date added" +msgstr "" + +#: ipam/models/ip.py:114 +msgid "aggregate" +msgstr "" + +#: ipam/models/ip.py:115 +msgid "aggregates" +msgstr "" + +#: ipam/models/ip.py:131 +msgid "Cannot create aggregate with /0 mask." +msgstr "" + +#: ipam/models/ip.py:143 +#, python-brace-format +msgid "" +"Aggregates cannot overlap. {prefix} is already covered by an existing " +"aggregate ({aggregate})." +msgstr "" + +#: ipam/models/ip.py:157 +#, python-brace-format +msgid "" +"Prefixes cannot overlap aggregates. {prefix} covers an existing aggregate " +"({aggregate})." +msgstr "" + +#: ipam/models/ip.py:199 ipam/models/ip.py:736 +msgid "role" +msgstr "" + +#: ipam/models/ip.py:200 +msgid "roles" +msgstr "" + +#: ipam/models/ip.py:216 ipam/models/ip.py:292 +msgid "prefix" +msgstr "" + +#: ipam/models/ip.py:217 +msgid "IPv4 or IPv6 network with mask" +msgstr "" + +#: ipam/models/ip.py:253 +msgid "Operational status of this prefix" +msgstr "" + +#: ipam/models/ip.py:261 +msgid "The primary function of this prefix" +msgstr "" + +#: ipam/models/ip.py:264 +msgid "is a pool" +msgstr "" + +#: ipam/models/ip.py:266 +msgid "All IP addresses within this prefix are considered usable" +msgstr "" + +#: ipam/models/ip.py:269 ipam/models/ip.py:536 +msgid "mark utilized" +msgstr "" + +#: ipam/models/ip.py:293 +msgid "prefixes" +msgstr "" + +#: ipam/models/ip.py:316 +msgid "Cannot create prefix with /0 mask." +msgstr "" + +#: ipam/models/ip.py:323 ipam/models/ip.py:853 +#, python-brace-format +msgid "VRF {vrf}" +msgstr "" + +#: ipam/models/ip.py:323 ipam/models/ip.py:853 +msgid "global table" +msgstr "" + +#: ipam/models/ip.py:325 +#, python-brace-format +msgid "Duplicate prefix found in {table}: {prefix}" +msgstr "" + +#: ipam/models/ip.py:494 +msgid "start address" +msgstr "" + +#: ipam/models/ip.py:495 ipam/models/ip.py:499 ipam/models/ip.py:711 +msgid "IPv4 or IPv6 address (with mask)" +msgstr "" + +#: ipam/models/ip.py:498 +msgid "end address" +msgstr "" + +#: ipam/models/ip.py:525 +msgid "Operational status of this range" +msgstr "" + +#: ipam/models/ip.py:533 +msgid "The primary function of this range" +msgstr "" + +#: ipam/models/ip.py:547 +msgid "IP range" +msgstr "" + +#: ipam/models/ip.py:548 +msgid "IP ranges" +msgstr "" + +#: ipam/models/ip.py:564 +msgid "Starting and ending IP address versions must match" +msgstr "" + +#: ipam/models/ip.py:570 +msgid "Starting and ending IP address masks must match" +msgstr "" + +#: ipam/models/ip.py:577 +#, python-brace-format +msgid "" +"Ending address must be lower than the starting address ({start_address})" +msgstr "" + +#: ipam/models/ip.py:589 +#, python-brace-format +msgid "Defined addresses overlap with range {overlapping_range} in VRF {vrf}" +msgstr "" + +#: ipam/models/ip.py:598 +#, python-brace-format +msgid "Defined range exceeds maximum supported size ({max_size})" +msgstr "" + +#: ipam/models/ip.py:710 tenancy/models/contacts.py:81 +msgid "address" +msgstr "" + +#: ipam/models/ip.py:733 +msgid "The operational status of this IP" +msgstr "" + +#: ipam/models/ip.py:740 +msgid "The functional role of this IP" +msgstr "" + +#: ipam/models/ip.py:764 templates/ipam/ipaddress.html:75 +msgid "NAT (inside)" +msgstr "" + +#: ipam/models/ip.py:765 +msgid "The IP for which this address is the \"outside\" IP" +msgstr "" + +#: ipam/models/ip.py:772 +msgid "Hostname or FQDN (not case-sensitive)" +msgstr "" + +#: ipam/models/ip.py:787 ipam/models/services.py:94 +msgid "IP addresses" +msgstr "" + +#: ipam/models/ip.py:843 +msgid "Cannot create IP address with /0 mask." +msgstr "" + +#: ipam/models/ip.py:855 +#, python-brace-format +msgid "Duplicate IP address found in {table}: {ipaddress}" +msgstr "" + +#: ipam/models/ip.py:884 +msgid "Only IPv6 addresses can be assigned SLAAC status" +msgstr "" + +#: ipam/models/l2vpn.py:64 netbox/navigation/menu.py:205 +msgid "L2VPNs" +msgstr "" + +#: ipam/models/l2vpn.py:113 +msgid "L2VPN termination" +msgstr "" + +#: ipam/models/l2vpn.py:114 +msgid "L2VPN terminations" +msgstr "" + +#: ipam/models/l2vpn.py:132 +#, python-brace-format +msgid "L2VPN Termination already assigned ({assigned_object})" +msgstr "" + +#: ipam/models/l2vpn.py:144 +#, python-brace-format +msgid "" +"{l2vpn_type} L2VPNs cannot have more than two terminations; found " +"{terminations_count} already defined." +msgstr "" + +#: ipam/models/services.py:33 +msgid "port numbers" +msgstr "" + +#: ipam/models/services.py:59 +msgid "service template" +msgstr "" + +#: ipam/models/services.py:60 +msgid "service templates" +msgstr "" + +#: ipam/models/services.py:95 +msgid "The specific IP addresses (if any) to which this service is bound" +msgstr "" + +#: ipam/models/services.py:102 +msgid "service" +msgstr "" + +#: ipam/models/services.py:103 +msgid "services" +msgstr "" + +#: ipam/models/services.py:117 +msgid "" +"A service cannot be associated with both a device and a virtual machine." +msgstr "" + +#: ipam/models/services.py:119 +msgid "A service must be associated with either a device or a virtual machine." +msgstr "" + +#: ipam/models/vlans.py:50 +msgid "minimum VLAN ID" +msgstr "" + +#: ipam/models/vlans.py:56 +msgid "Lowest permissible ID of a child VLAN" +msgstr "" + +#: ipam/models/vlans.py:59 +msgid "maximum VLAN ID" +msgstr "" + +#: ipam/models/vlans.py:65 +msgid "Highest permissible ID of a child VLAN" +msgstr "" + +#: ipam/models/vlans.py:83 +msgid "VLAN groups" +msgstr "" + +#: ipam/models/vlans.py:93 +msgid "Cannot set scope_type without scope_id." +msgstr "" + +#: ipam/models/vlans.py:95 +msgid "Cannot set scope_id without scope_type." +msgstr "" + +#: ipam/models/vlans.py:100 +msgid "Maximum child VID must be greater than or equal to minimum child VID" +msgstr "" + +#: ipam/models/vlans.py:143 +msgid "The specific site to which this VLAN is assigned (if any)" +msgstr "" + +#: ipam/models/vlans.py:151 +msgid "VLAN group (optional)" +msgstr "" + +#: ipam/models/vlans.py:159 +msgid "Numeric VLAN ID (1-4094)" +msgstr "" + +#: ipam/models/vlans.py:177 +msgid "Operational status of this VLAN" +msgstr "" + +#: ipam/models/vlans.py:185 +msgid "The primary function of this VLAN" +msgstr "" + +#: ipam/models/vlans.py:214 ipam/tables/ip.py:175 ipam/tables/vlans.py:78 +#: ipam/views.py:942 netbox/navigation/menu.py:181 +#: netbox/navigation/menu.py:183 +msgid "VLANs" +msgstr "" + +#: ipam/models/vlans.py:229 +#, python-brace-format +msgid "" +"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to " +"site {site}." +msgstr "" + +#: ipam/models/vlans.py:237 +#, python-brace-format +msgid "VID must be between {minimum} and {maximum} for VLANs in group {group}" +msgstr "" + +#: ipam/models/vrfs.py:30 +msgid "route distinguisher" +msgstr "" + +#: ipam/models/vrfs.py:31 +msgid "Unique route distinguisher (as defined in RFC 4364)" +msgstr "" + +#: ipam/models/vrfs.py:42 +msgid "enforce unique space" +msgstr "" + +#: ipam/models/vrfs.py:43 +msgid "Prevent duplicate prefixes/IP addresses within this VRF" +msgstr "" + +#: ipam/models/vrfs.py:63 netbox/navigation/menu.py:174 +#: netbox/navigation/menu.py:176 +msgid "VRFs" +msgstr "" + +#: ipam/models/vrfs.py:82 +msgid "Route target value (formatted in accordance with RFC 4360)" +msgstr "" + +#: ipam/models/vrfs.py:94 +msgid "route target" +msgstr "" + +#: ipam/models/vrfs.py:95 +msgid "route targets" +msgstr "" + +#: ipam/tables/asn.py:51 +msgid "ASDOT" +msgstr "" + +#: ipam/tables/asn.py:56 +msgid "Site Count" +msgstr "" + +#: ipam/tables/asn.py:61 +msgid "Provider Count" +msgstr "" + +#: ipam/tables/ip.py:94 netbox/navigation/menu.py:167 +#: netbox/navigation/menu.py:169 +msgid "Aggregates" +msgstr "" + +#: ipam/tables/ip.py:124 +msgid "Added" +msgstr "" + +#: ipam/tables/ip.py:127 ipam/tables/ip.py:165 ipam/tables/vlans.py:138 +#: ipam/views.py:351 netbox/navigation/menu.py:153 +#: netbox/navigation/menu.py:155 templates/ipam/vlan.html:87 +msgid "Prefixes" +msgstr "" + +#: ipam/tables/ip.py:130 ipam/tables/ip.py:267 ipam/tables/ip.py:320 +#: ipam/tables/vlans.py:82 templates/dcim/device.html:280 +#: templates/ipam/aggregate.html:25 templates/ipam/iprange.html:32 +#: templates/ipam/prefix.html:100 +msgid "Utilization" +msgstr "" + +#: ipam/tables/ip.py:170 netbox/navigation/menu.py:149 +msgid "IP Ranges" +msgstr "" + +#: ipam/tables/ip.py:220 +msgid "Prefix (Flat)" +msgstr "" + +#: ipam/tables/ip.py:224 templates/dcim/rack_edit.html:52 +msgid "Depth" +msgstr "" + +#: ipam/tables/ip.py:233 +msgid "Children" +msgstr "" + +#: ipam/tables/ip.py:261 +msgid "Pool" +msgstr "" + +#: ipam/tables/ip.py:264 ipam/tables/ip.py:317 +msgid "Marked Utilized" +msgstr "" + +#: ipam/tables/ip.py:301 +msgid "Start address" +msgstr "" + +#: ipam/tables/ip.py:379 +msgid "NAT (Inside)" +msgstr "" + +#: ipam/tables/ip.py:384 +msgid "NAT (Outside)" +msgstr "" + +#: ipam/tables/ip.py:389 +msgid "Assigned" +msgstr "" + +#: ipam/tables/l2vpn.py:27 ipam/tables/vrfs.py:36 +msgid "Import Targets" +msgstr "" + +#: ipam/tables/l2vpn.py:32 ipam/tables/vrfs.py:41 +msgid "Export Targets" +msgstr "" + +#: ipam/tables/l2vpn.py:69 +msgid "Object Parent" +msgstr "" + +#: ipam/tables/l2vpn.py:74 +msgid "Object Site" +msgstr "" + +#: ipam/tables/vlans.py:68 +msgid "Scope Type" +msgstr "" + +#: ipam/tables/vlans.py:107 ipam/tables/vlans.py:210 +#: templates/dcim/inc/interface_vlans_table.html:4 +msgid "VID" +msgstr "" + +#: ipam/tables/vrfs.py:30 +msgid "RD" +msgstr "" + +#: ipam/tables/vrfs.py:33 +msgid "Unique" +msgstr "" + +#: ipam/views.py:538 +msgid "Child Prefixes" +msgstr "" + +#: ipam/views.py:573 +msgid "Child Ranges" +msgstr "" + +#: ipam/views.py:870 +msgid "Related IPs" +msgstr "" + +#: ipam/views.py:1093 +msgid "Device Interfaces" +msgstr "" + +#: ipam/views.py:1111 +msgid "VM Interfaces" +msgstr "" + +#: netbox/config/parameters.py:22 templates/extras/configrevision.html:111 +msgid "Login banner" +msgstr "" + +#: netbox/config/parameters.py:24 +msgid "Additional content to display on the login page" +msgstr "" + +#: netbox/config/parameters.py:33 templates/extras/configrevision.html:115 +msgid "Maintenance banner" +msgstr "" + +#: netbox/config/parameters.py:35 +msgid "Additional content to display when in maintenance mode" +msgstr "" + +#: netbox/config/parameters.py:44 templates/extras/configrevision.html:119 +msgid "Top banner" +msgstr "" + +#: netbox/config/parameters.py:46 +msgid "Additional content to display at the top of every page" +msgstr "" + +#: netbox/config/parameters.py:55 templates/extras/configrevision.html:123 +msgid "Bottom banner" +msgstr "" + +#: netbox/config/parameters.py:57 +msgid "Additional content to display at the bottom of every page" +msgstr "" + +#: netbox/config/parameters.py:68 +msgid "Globally unique IP space" +msgstr "" + +#: netbox/config/parameters.py:70 +msgid "Enforce unique IP addressing within the global table" +msgstr "" + +#: netbox/config/parameters.py:75 templates/extras/configrevision.html:87 +msgid "Prefer IPv4" +msgstr "" + +#: netbox/config/parameters.py:77 +msgid "Prefer IPv4 addresses over IPv6" +msgstr "" + +#: netbox/config/parameters.py:84 +msgid "Rack unit height" +msgstr "" + +#: netbox/config/parameters.py:86 +msgid "Default unit height for rendered rack elevations" +msgstr "" + +#: netbox/config/parameters.py:91 +msgid "Rack unit width" +msgstr "" + +#: netbox/config/parameters.py:93 +msgid "Default unit width for rendered rack elevations" +msgstr "" + +#: netbox/config/parameters.py:100 +msgid "Powerfeed voltage" +msgstr "" + +#: netbox/config/parameters.py:102 +msgid "Default voltage for powerfeeds" +msgstr "" + +#: netbox/config/parameters.py:107 +msgid "Powerfeed amperage" +msgstr "" + +#: netbox/config/parameters.py:109 +msgid "Default amperage for powerfeeds" +msgstr "" + +#: netbox/config/parameters.py:114 +msgid "Powerfeed max utilization" +msgstr "" + +#: netbox/config/parameters.py:116 +msgid "Default max utilization for powerfeeds" +msgstr "" + +#: netbox/config/parameters.py:123 templates/extras/configrevision.html:99 +msgid "Allowed URL schemes" +msgstr "" + +#: netbox/config/parameters.py:128 +msgid "Permitted schemes for URLs in user-provided content" +msgstr "" + +#: netbox/config/parameters.py:136 +msgid "Default page size" +msgstr "" + +#: netbox/config/parameters.py:142 +msgid "Maximum page size" +msgstr "" + +#: netbox/config/parameters.py:150 templates/extras/configrevision.html:151 +msgid "Custom validators" +msgstr "" + +#: netbox/config/parameters.py:152 +msgid "Custom validation rules (JSON)" +msgstr "" + +#: netbox/config/parameters.py:164 +msgid "Default preferences" +msgstr "" + +#: netbox/config/parameters.py:166 +msgid "Default preferences for new users" +msgstr "" + +#: netbox/config/parameters.py:173 templates/extras/configrevision.html:175 +msgid "Maintenance mode" +msgstr "" + +#: netbox/config/parameters.py:175 +msgid "Enable maintenance mode" +msgstr "" + +#: netbox/config/parameters.py:180 templates/extras/configrevision.html:179 +msgid "GraphQL enabled" +msgstr "" + +#: netbox/config/parameters.py:182 +msgid "Enable the GraphQL API" +msgstr "" + +#: netbox/config/parameters.py:187 templates/extras/configrevision.html:183 +msgid "Changelog retention" +msgstr "" + +#: netbox/config/parameters.py:189 +msgid "Days to retain changelog history (set to zero for unlimited)" +msgstr "" + +#: netbox/config/parameters.py:194 +msgid "Job result retention" +msgstr "" + +#: netbox/config/parameters.py:196 +msgid "Days to retain job result history (set to zero for unlimited)" +msgstr "" + +#: netbox/config/parameters.py:201 templates/extras/configrevision.html:191 +msgid "Maps URL" +msgstr "" + +#: netbox/config/parameters.py:203 +msgid "Base URL for mapping geographic locations" +msgstr "" + +#: netbox/forms/__init__.py:13 +msgid "Partial match" +msgstr "" + +#: netbox/forms/__init__.py:14 +msgid "Exact match" +msgstr "" + +#: netbox/forms/__init__.py:15 +msgid "Starts with" +msgstr "" + +#: netbox/forms/__init__.py:16 +msgid "Ends with" +msgstr "" + +#: netbox/forms/__init__.py:17 +msgid "Regex" +msgstr "" + +#: netbox/forms/__init__.py:35 +msgid "Object type(s)" +msgstr "" + +#: netbox/forms/base.py:66 +msgid "Id" +msgstr "" + +#: netbox/forms/base.py:107 +msgid "Add tags" +msgstr "" + +#: netbox/forms/base.py:112 +msgid "Remove tags" +msgstr "" + +#: netbox/models/features.py:422 +msgid "Remote data source" +msgstr "" + +#: netbox/models/features.py:432 +msgid "data path" +msgstr "" + +#: netbox/models/features.py:436 +msgid "Path to remote file (relative to data source root)" +msgstr "" + +#: netbox/models/features.py:439 +msgid "auto sync enabled" +msgstr "" + +#: netbox/models/features.py:441 +msgid "Enable automatic synchronization of data when the data file is updated" +msgstr "" + +#: netbox/models/features.py:444 +msgid "date synced" +msgstr "" + +#: netbox/navigation/menu.py:12 +msgid "Organization" +msgstr "" + +#: netbox/navigation/menu.py:20 +msgid "Site Groups" +msgstr "" + +#: netbox/navigation/menu.py:28 +msgid "Rack Roles" +msgstr "" + +#: netbox/navigation/menu.py:32 +msgid "Elevations" +msgstr "" + +#: netbox/navigation/menu.py:41 +msgid "Tenant Groups" +msgstr "" + +#: netbox/navigation/menu.py:48 +msgid "Contact Groups" +msgstr "" + +#: netbox/navigation/menu.py:49 templates/tenancy/contactrole.html:8 +msgid "Contact Roles" +msgstr "" + +#: netbox/navigation/menu.py:50 +msgid "Contact Assignments" +msgstr "" + +#: netbox/navigation/menu.py:64 +msgid "Modules" +msgstr "" + +#: netbox/navigation/menu.py:65 templates/dcim/devicerole.html:8 +msgid "Device Roles" +msgstr "" + +#: netbox/navigation/menu.py:68 templates/dcim/device.html:179 +#: templates/dcim/virtualdevicecontext.html:8 +msgid "Virtual Device Contexts" +msgstr "" + +#: netbox/navigation/menu.py:76 +msgid "Manufacturers" +msgstr "" + +#: netbox/navigation/menu.py:80 +msgid "Device Components" +msgstr "" + +#: netbox/navigation/menu.py:92 templates/dcim/inventoryitemrole.html:8 +msgid "Inventory Item Roles" +msgstr "" + +#: netbox/navigation/menu.py:99 netbox/navigation/menu.py:103 +msgid "Connections" +msgstr "" + +#: netbox/navigation/menu.py:105 +msgid "Cables" +msgstr "" + +#: netbox/navigation/menu.py:106 +msgid "Wireless Links" +msgstr "" + +#: netbox/navigation/menu.py:109 +msgid "Interface Connections" +msgstr "" + +#: netbox/navigation/menu.py:114 +msgid "Console Connections" +msgstr "" + +#: netbox/navigation/menu.py:119 +msgid "Power Connections" +msgstr "" + +#: netbox/navigation/menu.py:135 +msgid "Wireless LAN Groups" +msgstr "" + +#: netbox/navigation/menu.py:156 +msgid "Prefix & VLAN Roles" +msgstr "" + +#: netbox/navigation/menu.py:162 +msgid "ASN Ranges" +msgstr "" + +#: netbox/navigation/menu.py:184 +msgid "VLAN Groups" +msgstr "" + +#: netbox/navigation/menu.py:191 +msgid "Service Templates" +msgstr "" + +#: netbox/navigation/menu.py:192 templates/dcim/device.html:321 +#: templates/ipam/ipaddress.html:122 +#: templates/virtualization/virtualmachine.html:155 +msgid "Services" +msgstr "" + +#: netbox/navigation/menu.py:199 +msgid "Overlay" +msgstr "" + +#: netbox/navigation/menu.py:206 templates/ipam/l2vpn.html:57 +msgid "Terminations" +msgstr "" + +#: netbox/navigation/menu.py:213 templates/dcim/device_edit.html:78 +msgid "Virtualization" +msgstr "" + +#: netbox/navigation/menu.py:217 netbox/navigation/menu.py:219 +#: virtualization/views.py:186 +msgid "Virtual Machines" +msgstr "" + +#: netbox/navigation/menu.py:227 +msgid "Cluster Types" +msgstr "" + +#: netbox/navigation/menu.py:228 +msgid "Cluster Groups" +msgstr "" + +#: netbox/navigation/menu.py:242 +msgid "Circuit Types" +msgstr "" + +#: netbox/navigation/menu.py:246 netbox/navigation/menu.py:248 +msgid "Providers" +msgstr "" + +#: netbox/navigation/menu.py:249 templates/circuits/provider.html:53 +msgid "Provider Accounts" +msgstr "" + +#: netbox/navigation/menu.py:250 +msgid "Provider Networks" +msgstr "" + +#: netbox/navigation/menu.py:264 +msgid "Power Panels" +msgstr "" + +#: netbox/navigation/menu.py:275 +msgid "Configurations" +msgstr "" + +#: netbox/navigation/menu.py:277 +msgid "Config Contexts" +msgstr "" + +#: netbox/navigation/menu.py:278 +msgid "Config Templates" +msgstr "" + +#: netbox/navigation/menu.py:285 netbox/navigation/menu.py:289 +msgid "Customization" +msgstr "" + +#: netbox/navigation/menu.py:291 +#: templates/circuits/circuittermination_edit.html:53 +#: templates/dcim/cable_edit.html:77 templates/dcim/device_edit.html:103 +#: templates/dcim/inventoryitem_edit.html:102 templates/dcim/rack_edit.html:81 +#: templates/dcim/virtualchassis_add.html:31 +#: templates/dcim/virtualchassis_edit.html:41 +#: templates/generic/bulk_edit.html:92 templates/htmx/form.html:32 +#: templates/inc/panels/custom_fields.html:7 +#: templates/ipam/ipaddress_bulk_add.html:35 +#: templates/ipam/ipaddress_edit.html:88 +#: templates/ipam/l2vpntermination_edit.html:51 +#: templates/ipam/service_create.html:75 templates/ipam/service_edit.html:62 +#: templates/ipam/vlan_edit.html:63 +msgid "Custom Fields" +msgstr "" + +#: netbox/navigation/menu.py:292 +msgid "Custom Field Choices" +msgstr "" + +#: netbox/navigation/menu.py:293 +msgid "Custom Links" +msgstr "" + +#: netbox/navigation/menu.py:294 +msgid "Export Templates" +msgstr "" + +#: netbox/navigation/menu.py:295 +msgid "Saved Filters" +msgstr "" + +#: netbox/navigation/menu.py:297 +msgid "Image Attachments" +msgstr "" + +#: netbox/navigation/menu.py:301 +msgid "Reports & Scripts" +msgstr "" + +#: netbox/navigation/menu.py:321 +msgid "Operations" +msgstr "" + +#: netbox/navigation/menu.py:325 +msgid "Integrations" +msgstr "" + +#: netbox/navigation/menu.py:327 +msgid "Data Sources" +msgstr "" + +#: netbox/navigation/menu.py:328 +msgid "Webhooks" +msgstr "" + +#: netbox/navigation/menu.py:332 netbox/navigation/menu.py:336 +#: netbox/views/generic/feature_views.py:151 +#: templates/extras/report/base.html:37 templates/extras/script/base.html:36 +msgid "Jobs" +msgstr "" + +#: netbox/navigation/menu.py:342 +msgid "Logging" +msgstr "" + +#: netbox/navigation/menu.py:344 +msgid "Journal Entries" +msgstr "" + +#: netbox/navigation/menu.py:345 templates/extras/objectchange.html:8 +#: templates/extras/objectchange_list.html:4 +msgid "Change Log" +msgstr "" + +#: netbox/navigation/menu.py:352 templates/inc/profile_button.html:18 +msgid "Admin" +msgstr "" + +#: netbox/navigation/menu.py:361 templates/users/group.html:27 +#: users/forms/model_forms.py:242 users/forms/model_forms.py:255 +#: users/forms/model_forms.py:309 users/tables.py:105 +msgid "Users" +msgstr "" + +#: netbox/navigation/menu.py:384 users/forms/model_forms.py:182 +#: users/forms/model_forms.py:195 users/forms/model_forms.py:314 +#: users/tables.py:35 users/tables.py:109 +msgid "Groups" +msgstr "" + +#: netbox/navigation/menu.py:406 templates/account/base.html:21 +#: templates/inc/profile_button.html:39 +msgid "API Tokens" +msgstr "" + +#: netbox/navigation/menu.py:413 users/forms/model_forms.py:188 +#: users/forms/model_forms.py:197 users/forms/model_forms.py:248 +#: users/forms/model_forms.py:256 +msgid "Permissions" +msgstr "" + +#: netbox/navigation/menu.py:425 +msgid "Current Config" +msgstr "" + +#: netbox/navigation/menu.py:431 +msgid "Config Revisions" +msgstr "" + +#: netbox/navigation/menu.py:471 templates/500.html:35 +#: templates/account/preferences.html:29 +msgid "Plugins" +msgstr "" + +#: netbox/preferences.py:17 +msgid "Color mode" +msgstr "" + +#: netbox/preferences.py:25 +msgid "Page length" +msgstr "" + +#: netbox/preferences.py:27 +msgid "The default number of objects to display per page" +msgstr "" + +#: netbox/preferences.py:31 +msgid "Paginator placement" +msgstr "" + +#: netbox/preferences.py:37 +msgid "Where the paginator controls will be displayed relative to a table" +msgstr "" + +#: netbox/preferences.py:43 +msgid "Data format" +msgstr "" + +#: netbox/tables/columns.py:175 +msgid "Toggle all" +msgstr "" + +#: netbox/tables/columns.py:277 templates/inc/profile_button.html:56 +msgid "Toggle Dropdown" +msgstr "" + +#: netbox/tables/columns.py:542 +msgid "Error" +msgstr "" + +#: netbox/tables/tables.py:234 templates/generic/bulk_import.html:115 +msgid "Field" +msgstr "" + +#: netbox/tables/tables.py:237 +msgid "Value" +msgstr "" + +#: netbox/tables/tables.py:246 +msgid "No results found" +msgstr "" + +#: netbox/tests/dummy_plugin/navigation.py:29 +msgid "Dummy Plugin" +msgstr "" + +#: netbox/views/generic/feature_views.py:38 +msgid "Changelog" +msgstr "" + +#: netbox/views/generic/feature_views.py:91 +msgid "Journal" +msgstr "" + +#: templates/403.html:4 +msgid "Access Denied" +msgstr "" + +#: templates/403.html:9 +msgid "You do not have permission to access this page" +msgstr "" + +#: templates/404.html:4 +msgid "Page Not Found" +msgstr "" + +#: templates/404.html:9 +msgid "The requested page does not exist" +msgstr "" + +#: templates/500.html:7 templates/500.html:18 +msgid "Server Error" +msgstr "" + +#: templates/500.html:23 +msgid "There was a problem with your request. Please contact an administrator" +msgstr "" + +#: templates/500.html:28 +msgid "The complete exception is provided below" +msgstr "" + +#: templates/500.html:33 +msgid "Python version" +msgstr "" + +#: templates/500.html:34 +msgid "NetBox version" +msgstr "" + +#: templates/500.html:36 +msgid "None installed" +msgstr "" + +#: templates/500.html:39 +msgid "If further assistance is required, please post to the" +msgstr "" + +#: templates/500.html:39 +msgid "NetBox discussion forum" +msgstr "" + +#: templates/500.html:39 +msgid "on GitHub" +msgstr "" + +#: templates/500.html:42 templates/base/40x.html:17 +msgid "Home Page" +msgstr "" + +#: templates/account/base.html:7 templates/inc/profile_button.html:24 +msgid "Profile" +msgstr "" + +#: templates/account/base.html:13 templates/inc/profile_button.html:34 +msgid "Preferences" +msgstr "" + +#: templates/account/password.html:5 +msgid "Change Password" +msgstr "" + +#: templates/account/password.html:17 templates/account/preferences.html:82 +#: templates/dcim/devicebay_populate.html:34 +#: templates/dcim/virtualchassis_add_member.html:24 +#: templates/dcim/virtualchassis_edit.html:104 +#: templates/extras/configrevision_restore.html:80 +#: templates/extras/object_journal.html:26 templates/extras/script.html:36 +#: templates/generic/bulk_add_component.html:55 +#: templates/generic/bulk_delete.html:46 templates/generic/bulk_edit.html:125 +#: templates/generic/bulk_import.html:53 templates/generic/bulk_import.html:75 +#: templates/generic/bulk_import.html:97 templates/generic/bulk_remove.html:42 +#: templates/generic/bulk_rename.html:44 +#: templates/generic/confirmation_form.html:20 +#: templates/generic/object_edit.html:76 templates/htmx/delete_form.html:19 +#: templates/htmx/delete_form.html:21 templates/ipam/ipaddress_assign.html:31 +#: templates/virtualization/cluster_add_devices.html:30 +msgid "Cancel" +msgstr "" + +#: templates/account/password.html:18 templates/account/preferences.html:83 +#: templates/dcim/devicebay_populate.html:35 +#: templates/dcim/virtualchassis_add_member.html:26 +#: templates/dcim/virtualchassis_edit.html:106 +#: templates/extras/dashboard/widget_add.html:26 +#: templates/extras/dashboard/widget_config.html:19 +#: templates/extras/object_journal.html:27 +#: templates/generic/object_edit.html:66 +#: utilities/templates/helpers/applied_filters.html:16 +#: utilities/templates/helpers/table_config_form.html:40 +msgid "Save" +msgstr "" + +#: templates/account/preferences.html:41 +msgid "Table Configurations" +msgstr "" + +#: templates/account/preferences.html:46 +msgid "Clear table preferences" +msgstr "" + +#: templates/account/preferences.html:53 +msgid "Toggle All" +msgstr "" + +#: templates/account/preferences.html:55 +msgid "Table" +msgstr "" + +#: templates/account/preferences.html:56 +msgid "Ordering" +msgstr "" + +#: templates/account/preferences.html:57 +msgid "Columns" +msgstr "" + +#: templates/account/preferences.html:76 templates/dcim/cable_trace.html:113 +#: templates/extras/object_configcontext.html:55 +msgid "None found" +msgstr "" + +#: templates/account/profile.html:6 +msgid "User Profile" +msgstr "" + +#: templates/account/profile.html:12 +msgid "Account Details" +msgstr "" + +#: templates/account/profile.html:30 templates/tenancy/contact.html:44 +#: templates/users/user.html:26 tenancy/forms/bulk_edit.py:108 +msgid "Email" +msgstr "" + +#: templates/account/profile.html:34 templates/users/user.html:30 +msgid "Account Created" +msgstr "" + +#: templates/account/profile.html:38 templates/users/user.html:42 +msgid "Superuser" +msgstr "" + +#: templates/account/profile.html:42 +msgid "Admin Access" +msgstr "" + +#: templates/account/profile.html:51 templates/users/objectpermission.html:86 +#: templates/users/user.html:51 +msgid "Assigned Groups" +msgstr "" + +#: templates/account/profile.html:56 +#: templates/circuits/circuit_terminations_swap.html:18 +#: templates/circuits/circuit_terminations_swap.html:26 +#: templates/circuits/inc/circuit_termination.html:154 +#: templates/dcim/devicebay.html:66 +#: templates/dcim/inc/panels/inventory_items.html:37 +#: templates/dcim/interface.html:302 templates/dcim/modulebay.html:79 +#: templates/extras/configcontext.html:73 +#: templates/extras/htmx/script_result.html:54 +#: templates/extras/object_configcontext.html:28 +#: templates/extras/objectchange.html:128 +#: templates/extras/objectchange.html:145 templates/extras/webhook.html:122 +#: templates/extras/webhook.html:134 templates/extras/webhook.html:146 +#: templates/inc/panel_table.html:12 templates/inc/panels/comments.html:12 +#: templates/ipam/inc/panels/fhrp_groups.html:43 templates/users/group.html:32 +#: templates/users/group.html:42 templates/users/objectpermission.html:81 +#: templates/users/objectpermission.html:91 templates/users/user.html:56 +#: templates/users/user.html:66 +msgid "None" +msgstr "" + +#: templates/account/profile.html:66 templates/users/user.html:76 +msgid "Recent Activity" +msgstr "" + +#: templates/account/token.html:8 templates/account/token_list.html:6 +msgid "My API Tokens" +msgstr "" + +#: templates/account/token.html:11 templates/account/token.html:19 +#: templates/users/token.html:6 templates/users/token.html:14 +#: users/forms/filtersets.py:123 +msgid "Token" +msgstr "" + +#: templates/account/token.html:40 templates/users/token.html:32 +#: users/forms/bulk_edit.py:87 +msgid "Write enabled" +msgstr "" + +#: templates/account/token.html:52 templates/users/token.html:44 +msgid "Last used" +msgstr "" + +#: templates/account/token_list.html:12 +msgid "Add a Token" +msgstr "" + +#: templates/admin/index.html:10 +msgid "System" +msgstr "" + +#: templates/admin/index.html:14 +msgid "Background Tasks" +msgstr "" + +#: templates/admin/index.html:19 +msgid "Installed plugins" +msgstr "" + +#: templates/base/base.html:28 templates/extras/admin/plugins_list.html:8 +#: templates/home.html:24 +msgid "Home" +msgstr "" + +#: templates/base/layout.html:27 templates/base/layout.html:37 +#: templates/login.html:34 +msgid "NetBox logo" +msgstr "" + +#: templates/base/layout.html:76 +msgid "Debug mode is enabled" +msgstr "" + +#: templates/base/layout.html:77 +msgid "" +"Performance may be limited. Debugging should never be enabled on a " +"production system" +msgstr "" + +#: templates/base/layout.html:83 +msgid "Maintenance Mode" +msgstr "" + +#: templates/base/layout.html:134 +msgid "Docs" +msgstr "" + +#: templates/base/layout.html:139 templates/rest_framework/api.html:10 +msgid "REST API" +msgstr "" + +#: templates/base/layout.html:144 +msgid "REST API documentation" +msgstr "" + +#: templates/base/layout.html:150 +msgid "GraphQL API" +msgstr "" + +#: templates/base/layout.html:156 +msgid "Source Code" +msgstr "" + +#: templates/base/layout.html:161 +msgid "Community" +msgstr "" + +#: templates/base/sidenav.html:12 templates/base/sidenav.html:17 +msgid "NetBox Logo" +msgstr "" + +#: templates/circuits/circuit.html:48 +msgid "Install Date" +msgstr "" + +#: templates/circuits/circuit.html:52 +msgid "Termination Date" +msgstr "" + +#: templates/circuits/circuit_terminations_swap.html:4 +msgid "Swap Circuit Terminations" +msgstr "" + +#: templates/circuits/circuit_terminations_swap.html:8 +#, python-format +msgid "Swap these terminations for circuit %(circuit)s?" +msgstr "" + +#: templates/circuits/circuit_terminations_swap.html:14 +msgid "A side" +msgstr "" + +#: templates/circuits/circuit_terminations_swap.html:22 +msgid "Z side" +msgstr "" + +#: templates/circuits/circuittermination_edit.html:9 +#: templates/circuits/inc/circuit_termination.html:81 +#: templates/dcim/frontport.html:128 templates/dcim/interface.html:195 +#: templates/dcim/rearport.html:118 +msgid "Circuit Termination" +msgstr "" + +#: templates/circuits/circuittermination_edit.html:41 +msgid "Termination Details" +msgstr "" + +#: templates/circuits/circuittype.html:10 +msgid "Add Circuit" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:9 +#: templates/dcim/devicetype/component_templates.html:30 +#: templates/dcim/manufacturer.html:11 +#: templates/dcim/moduletype/component_templates.html:30 +#: templates/generic/bulk_add_component.html:8 +#: templates/users/objectpermission.html:41 +#: utilities/templates/buttons/add.html:4 +#: utilities/templates/helpers/table_config_form.html:20 +msgid "Add" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:14 +#: templates/circuits/inc/circuit_termination.html:63 +#: templates/dcim/devicetype/component_templates.html:21 +#: templates/dcim/inc/panels/inventory_items.html:24 +#: templates/dcim/moduletype/component_templates.html:21 +#: templates/dcim/powerpanel.html:61 templates/generic/object_edit.html:29 +#: templates/ipam/inc/ipaddress_edit_header.html:10 +#: templates/ipam/inc/panels/fhrp_groups.html:30 +#: utilities/templates/buttons/edit.html:3 +msgid "Edit" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:17 +msgid "Swap" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:26 +#, python-format +msgid "Termination %(side)s" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:42 +#: templates/dcim/cable.html:70 templates/dcim/cable.html:76 +msgid "Termination" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:46 +#: templates/dcim/consoleport.html:62 templates/dcim/consoleserverport.html:62 +#: templates/dcim/powerfeed.html:122 +msgid "Marked as connected" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:48 +msgid "to" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:58 +#: templates/circuits/inc/circuit_termination.html:59 +#: templates/dcim/frontport.html:87 +#: templates/dcim/inc/connection_endpoints.html:7 +#: templates/dcim/interface.html:156 templates/dcim/rearport.html:83 +msgid "Trace" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:62 +msgid "Edit cable" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:67 +msgid "Remove cable" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:68 +#: templates/dcim/bulk_disconnect.html:5 +#: templates/dcim/device/consoleports.html:12 +#: templates/dcim/device/consoleserverports.html:12 +#: templates/dcim/device/frontports.html:12 +#: templates/dcim/device/interfaces.html:16 +#: templates/dcim/device/poweroutlets.html:12 +#: templates/dcim/device/powerports.html:12 +#: templates/dcim/device/rearports.html:12 templates/dcim/powerpanel.html:66 +msgid "Disconnect" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:75 +#: templates/dcim/consoleport.html:71 templates/dcim/consoleserverport.html:71 +#: templates/dcim/frontport.html:109 templates/dcim/interface.html:182 +#: templates/dcim/interface.html:202 templates/dcim/powerfeed.html:136 +#: templates/dcim/poweroutlet.html:75 templates/dcim/poweroutlet.html:76 +#: templates/dcim/powerport.html:77 templates/dcim/rearport.html:105 +msgid "Connect" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:79 +#: templates/dcim/consoleport.html:78 templates/dcim/consoleserverport.html:78 +#: templates/dcim/frontport.html:18 templates/dcim/frontport.html:122 +#: templates/dcim/interface.html:189 templates/dcim/inventoryitem_edit.html:49 +#: templates/dcim/rearport.html:112 +msgid "Front Port" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:97 +msgid "Downstream" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:98 +msgid "Upstream" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:107 +msgid "Cross-Connect" +msgstr "" + +#: templates/circuits/inc/circuit_termination.html:111 +msgid "Patch Panel/Port" +msgstr "" + +#: templates/circuits/provider.html:11 +msgid "Add circuit" +msgstr "" + +#: templates/circuits/provideraccount.html:17 +msgid "Provider Account" +msgstr "" + +#: templates/core/datafile.html:47 +msgid "Last Updated" +msgstr "" + +#: templates/core/datafile.html:51 templates/ipam/iprange.html:28 +msgid "Size" +msgstr "" + +#: templates/core/datafile.html:52 +msgid "bytes" +msgstr "" + +#: templates/core/datafile.html:55 +msgid "SHA256 Hash" +msgstr "" + +#: templates/core/datasource.html:14 templates/core/datasource.html:20 +#: utilities/templates/buttons/sync.html:5 +msgid "Sync" +msgstr "" + +#: templates/core/datasource.html:51 +msgid "Last synced" +msgstr "" + +#: templates/core/datasource.html:86 +msgid "Backend" +msgstr "" + +#: templates/core/datasource.html:102 +msgid "No parameters defined" +msgstr "" + +#: templates/core/datasource.html:118 +msgid "Files" +msgstr "" + +#: templates/core/job.html:21 +msgid "Job" +msgstr "" + +#: templates/core/job.html:39 templates/extras/journalentry.html:29 +msgid "Created By" +msgstr "" + +#: templates/core/job.html:48 +msgid "Scheduling" +msgstr "" + +#: templates/core/job.html:60 +#, python-format +msgid "every %(interval)s seconds" +msgstr "" + +#: templates/dcim/bulk_disconnect.html:9 +#, python-format +msgid "" +"Are you sure you want to disconnect these %(count)s %(obj_type_plural)s?" +msgstr "" + +#: templates/dcim/cable_edit.html:12 +msgid "A Side" +msgstr "" + +#: templates/dcim/cable_edit.html:29 +msgid "B Side" +msgstr "" + +#: templates/dcim/cable_trace.html:6 +#, python-format +msgid "Cable Trace for %(object_type)s %(object)s" +msgstr "" + +#: templates/dcim/cable_trace.html:21 templates/dcim/inc/rack_elevation.html:7 +msgid "Download SVG" +msgstr "" + +#: templates/dcim/cable_trace.html:27 +msgid "Asymmetric Path" +msgstr "" + +#: templates/dcim/cable_trace.html:28 +msgid "The nodes below have no links and result in an asymmetric path" +msgstr "" + +#: templates/dcim/cable_trace.html:35 +msgid "Path split" +msgstr "" + +#: templates/dcim/cable_trace.html:36 +msgid "Select a node below to continue" +msgstr "" + +#: templates/dcim/cable_trace.html:52 +msgid "Trace Completed" +msgstr "" + +#: templates/dcim/cable_trace.html:55 +msgid "Total segments" +msgstr "" + +#: templates/dcim/cable_trace.html:59 +msgid "Total length" +msgstr "" + +#: templates/dcim/cable_trace.html:74 +msgid "No paths found" +msgstr "" + +#: templates/dcim/cable_trace.html:83 +msgid "Related Paths" +msgstr "" + +#: templates/dcim/cable_trace.html:89 +msgid "Origin" +msgstr "" + +#: templates/dcim/cable_trace.html:90 +msgid "Destination" +msgstr "" + +#: templates/dcim/cable_trace.html:91 +msgid "Segments" +msgstr "" + +#: templates/dcim/cable_trace.html:104 +msgid "Incomplete" +msgstr "" + +#: templates/dcim/component_list.html:14 +msgid "Rename Selected" +msgstr "" + +#: templates/dcim/consoleport.html:67 templates/dcim/consoleserverport.html:67 +#: templates/dcim/frontport.html:105 templates/dcim/interface.html:178 +#: templates/dcim/poweroutlet.html:73 templates/dcim/powerport.html:73 +msgid "Not Connected" +msgstr "" + +#: templates/dcim/consoleport.html:75 templates/dcim/consoleserverport.html:18 +#: templates/dcim/frontport.html:116 templates/dcim/inventoryitem_edit.html:44 +msgid "Console Server Port" +msgstr "" + +#: templates/dcim/device.html:52 +msgid "Highlight device" +msgstr "" + +#: templates/dcim/device.html:74 +msgid "Not racked" +msgstr "" + +#: templates/dcim/device.html:81 templates/dcim/site.html:109 +msgid "GPS Coordinates" +msgstr "" + +#: templates/dcim/device.html:87 templates/dcim/site.html:115 +msgid "Map It" +msgstr "" + +#: templates/dcim/device.html:127 templates/dcim/inventoryitem.html:57 +#: templates/dcim/module.html:79 templates/dcim/modulebay.html:73 +#: templates/dcim/rack.html:69 +msgid "Asset Tag" +msgstr "" + +#: templates/dcim/device.html:170 +msgid "View Virtual Chassis" +msgstr "" + +#: templates/dcim/device.html:187 +msgid "Create VDC" +msgstr "" + +#: templates/dcim/device.html:196 templates/dcim/device_edit.html:64 +#: virtualization/forms/model_forms.py:224 +msgid "Management" +msgstr "" + +#: templates/dcim/device.html:217 templates/dcim/device.html:233 +#: templates/virtualization/virtualmachine.html:56 +#: templates/virtualization/virtualmachine.html:72 +msgid "NAT for" +msgstr "" + +#: templates/dcim/device.html:219 templates/dcim/device.html:235 +#: templates/virtualization/virtualmachine.html:58 +#: templates/virtualization/virtualmachine.html:74 +msgid "NAT" +msgstr "" + +#: templates/dcim/device.html:271 templates/dcim/rack.html:77 +msgid "Power Utilization" +msgstr "" + +#: templates/dcim/device.html:276 +msgid "Input" +msgstr "" + +#: templates/dcim/device.html:277 +msgid "Outlets" +msgstr "" + +#: templates/dcim/device.html:278 +msgid "Allocated" +msgstr "" + +#: templates/dcim/device.html:287 templates/dcim/device.html:289 +#: templates/dcim/device.html:305 templates/dcim/powerfeed.html:70 +msgid "VA" +msgstr "" + +#: templates/dcim/device.html:299 +msgctxt "Leg of a power feed" +msgid "Leg" +msgstr "" + +#: templates/dcim/device.html:329 +#: templates/virtualization/virtualmachine.html:163 +msgid "Add a service" +msgstr "" + +#: templates/dcim/device.html:336 templates/dcim/rack.html:84 +#: templates/dcim/rack_edit.html:38 +msgid "Dimensions" +msgstr "" + +#: templates/dcim/device/base.html:21 templates/dcim/device_list.html:9 +#: templates/dcim/devicetype/base.html:18 templates/dcim/module.html:18 +#: templates/dcim/moduletype/base.html:18 +#: templates/virtualization/virtualmachine_list.html:8 +msgid "Add Components" +msgstr "" + +#: templates/dcim/device/consoleports.html:24 +msgid "Add Console Ports" +msgstr "" + +#: templates/dcim/device/consoleserverports.html:24 +msgid "Add Console Server Ports" +msgstr "" + +#: templates/dcim/device/devicebays.html:10 +msgid "Add Device Bays" +msgstr "" + +#: templates/dcim/device/frontports.html:24 +msgid "Add Front Ports" +msgstr "" + +#: templates/dcim/device/inc/interface_table_controls.html:9 +msgid "Hide Enabled" +msgstr "" + +#: templates/dcim/device/inc/interface_table_controls.html:10 +msgid "Hide Disabled" +msgstr "" + +#: templates/dcim/device/inc/interface_table_controls.html:11 +msgid "Hide Virtual" +msgstr "" + +#: templates/dcim/device/inc/interface_table_controls.html:12 +msgid "Hide Disconnected" +msgstr "" + +#: templates/dcim/device/interfaces.html:28 +#: templates/virtualization/virtualmachine/base.html:21 +msgid "Add Interfaces" +msgstr "" + +#: templates/dcim/device/inventory.html:10 +#: templates/dcim/inc/panels/inventory_items.html:46 +msgid "Add Inventory Item" +msgstr "" + +#: templates/dcim/device/modulebays.html:10 +msgid "Add Module Bays" +msgstr "" + +#: templates/dcim/device/poweroutlets.html:24 +msgid "Add Power Outlets" +msgstr "" + +#: templates/dcim/device/powerports.html:24 +msgid "Add Power Port" +msgstr "" + +#: templates/dcim/device/rearports.html:24 +msgid "Add Rear Ports" +msgstr "" + +#: templates/dcim/device/render_config.html:5 +#: templates/virtualization/virtualmachine/render_config.html:5 +msgid "Config" +msgstr "" + +#: templates/dcim/device/render_config.html:37 +#: templates/virtualization/virtualmachine/render_config.html:37 +msgid "Context Data" +msgstr "" + +#: templates/dcim/device/render_config.html:57 +#: templates/virtualization/virtualmachine/render_config.html:57 +msgid "Download" +msgstr "" + +#: templates/dcim/device/render_config.html:60 +#: templates/virtualization/virtualmachine/render_config.html:60 +msgid "Rendered Config" +msgstr "" + +#: templates/dcim/device/render_config.html:65 +#: templates/virtualization/virtualmachine/render_config.html:65 +msgid "No configuration template found" +msgstr "" + +#: templates/dcim/device_edit.html:44 +msgid "Parent Bay" +msgstr "" + +#: templates/dcim/device_edit.html:48 +#: utilities/templates/form_helpers/render_field.html:20 +msgid "Regenerate Slug" +msgstr "" + +#: templates/dcim/device_edit.html:49 templates/generic/bulk_remove.html:7 +#: utilities/templates/helpers/table_config_form.html:23 +msgid "Remove" +msgstr "" + +#: templates/dcim/device_edit.html:110 +msgid "Local Config Context Data" +msgstr "" + +#: templates/dcim/device_list.html:82 +#: templates/dcim/devicetype/component_templates.html:18 +#: templates/dcim/moduletype/component_templates.html:18 +#: templates/generic/bulk_rename.html:34 +#: templates/virtualization/virtualmachine/interfaces.html:11 +msgid "Rename" +msgstr "" + +#: templates/dcim/devicebay.html:18 +msgid "Device Bay" +msgstr "" + +#: templates/dcim/devicebay.html:48 +msgid "Installed Device" +msgstr "" + +#: templates/dcim/devicebay_delete.html:6 +#, python-format +msgid "Delete device bay %(devicebay)s?" +msgstr "" + +#: templates/dcim/devicebay_delete.html:11 +#, python-format +msgid "" +"Are you sure you want to delete this device bay from %(device)s?" +msgstr "" + +#: templates/dcim/devicebay_depopulate.html:6 +#, python-format +msgid "Remove %(device)s from %(device_bay)s?" +msgstr "" + +#: templates/dcim/devicebay_depopulate.html:13 +#, python-format +msgid "" +"Are you sure you want to remove %(device)s from " +"%(device_bay)s?" +msgstr "" + +#: templates/dcim/devicebay_populate.html:13 +msgid "Populate" +msgstr "" + +#: templates/dcim/devicebay_populate.html:22 +msgid "Bay" +msgstr "" + +#: templates/dcim/devicerole.html:14 templates/dcim/platform.html:17 +msgid "Add Device" +msgstr "" + +#: templates/dcim/devicerole.html:43 +msgid "VM Role" +msgstr "" + +#: templates/dcim/devicetype.html:21 templates/dcim/moduletype.html:19 +msgid "Model Name" +msgstr "" + +#: templates/dcim/devicetype.html:28 templates/dcim/moduletype.html:23 +msgid "Part Number" +msgstr "" + +#: templates/dcim/devicetype.html:40 +msgid "Height (U" +msgstr "" + +#: templates/dcim/devicetype.html:44 +msgid "Exclude From Utilization" +msgstr "" + +#: templates/dcim/devicetype.html:62 +msgid "Parent/Child" +msgstr "" + +#: templates/dcim/devicetype.html:74 +msgid "Front Image" +msgstr "" + +#: templates/dcim/devicetype.html:86 +msgid "Rear Image" +msgstr "" + +#: templates/dcim/frontport.html:57 +msgid "Rear Port Position" +msgstr "" + +#: templates/dcim/frontport.html:79 templates/dcim/interface.html:146 +#: templates/dcim/poweroutlet.html:67 templates/dcim/powerport.html:67 +#: templates/dcim/rearport.html:75 +msgid "Marked as Connected" +msgstr "" + +#: templates/dcim/frontport.html:93 templates/dcim/rearport.html:89 +msgid "Connection Status" +msgstr "" + +#: templates/dcim/inc/cable_termination.html:65 +msgid "No termination" +msgstr "" + +#: templates/dcim/inc/cable_toggle_buttons.html:4 +msgid "Mark Planned" +msgstr "" + +#: templates/dcim/inc/cable_toggle_buttons.html:8 +msgid "Mark Installed" +msgstr "" + +#: templates/dcim/inc/connection_endpoints.html:13 +msgid "Path Status" +msgstr "" + +#: templates/dcim/inc/connection_endpoints.html:18 +msgid "Not Reachable" +msgstr "" + +#: templates/dcim/inc/connection_endpoints.html:23 +msgid "Path Endpoints" +msgstr "" + +#: templates/dcim/inc/endpoint_connection.html:8 +#: templates/dcim/powerfeed.html:128 templates/dcim/rearport.html:101 +msgid "Not connected" +msgstr "" + +#: templates/dcim/inc/interface_vlans_table.html:6 +msgid "Untagged" +msgstr "" + +#: templates/dcim/inc/interface_vlans_table.html:37 +msgid "No VLANs Assigned" +msgstr "" + +#: templates/dcim/inc/interface_vlans_table.html:44 +#: templates/ipam/prefix_list.html:16 templates/ipam/prefix_list.html:33 +msgid "Clear" +msgstr "" + +#: templates/dcim/inc/interface_vlans_table.html:47 +msgid "Clear All" +msgstr "" + +#: templates/dcim/interface.html:17 +msgid "Add Child Interface" +msgstr "" + +#: templates/dcim/interface.html:51 +msgid "Speed/Duplex" +msgstr "" + +#: templates/dcim/interface.html:74 +msgid "PoE Mode" +msgstr "" + +#: templates/dcim/interface.html:78 +msgid "PoE Type" +msgstr "" + +#: templates/dcim/interface.html:82 +#: templates/virtualization/vminterface.html:66 +msgid "802.1Q Mode" +msgstr "" + +#: templates/dcim/interface.html:126 +#: templates/virtualization/vminterface.html:62 +msgid "MAC Address" +msgstr "" + +#: templates/dcim/interface.html:153 +msgid "Wireless Link" +msgstr "" + +#: templates/dcim/interface.html:222 +msgid "Peer" +msgstr "" + +#: templates/dcim/interface.html:234 +#: templates/wireless/inc/wirelesslink_interface.html:26 +msgid "Channel" +msgstr "" + +#: templates/dcim/interface.html:243 +#: templates/wireless/inc/wirelesslink_interface.html:32 +msgid "Channel Frequency" +msgstr "" + +#: templates/dcim/interface.html:246 templates/dcim/interface.html:254 +#: templates/dcim/interface.html:265 templates/dcim/interface.html:273 +msgid "MHz" +msgstr "" + +#: templates/dcim/interface.html:262 +#: templates/wireless/inc/wirelesslink_interface.html:42 +msgid "Channel Width" +msgstr "" + +#: templates/dcim/interface.html:291 templates/wireless/wirelesslan.html:15 +#: templates/wireless/wirelesslink.html:24 wireless/forms/bulk_edit.py:59 +#: wireless/forms/bulk_edit.py:101 wireless/forms/filtersets.py:39 +#: wireless/forms/filtersets.py:79 wireless/models.py:81 wireless/models.py:155 +#: wireless/tables/wirelesslan.py:44 +msgid "SSID" +msgstr "" + +#: templates/dcim/interface.html:312 +msgid "LAG Members" +msgstr "" + +#: templates/dcim/interface.html:331 +msgid "No member interfaces" +msgstr "" + +#: templates/dcim/interface.html:355 templates/ipam/fhrpgroup.html:80 +#: templates/ipam/iprange/ip_addresses.html:7 +#: templates/ipam/prefix/ip_addresses.html:7 +#: templates/virtualization/vminterface.html:92 +msgid "Add IP Address" +msgstr "" + +#: templates/dcim/inventoryitem.html:25 +msgid "Parent Item" +msgstr "" + +#: templates/dcim/inventoryitem.html:49 +msgid "Part ID" +msgstr "" + +#: templates/dcim/inventoryitem_bulk_delete.html:5 +msgid "This will also delete all child inventory items of those listed" +msgstr "" + +#: templates/dcim/inventoryitem_edit.html:33 +msgid "Component Assignment" +msgstr "" + +#: templates/dcim/inventoryitem_edit.html:59 templates/dcim/poweroutlet.html:18 +#: templates/dcim/powerport.html:81 +msgid "Power Outlet" +msgstr "" + +#: templates/dcim/location.html:17 +msgid "Add Child Location" +msgstr "" + +#: templates/dcim/location.html:76 +msgid "Child Locations" +msgstr "" + +#: templates/dcim/location.html:84 templates/dcim/site.html:150 +msgid "Add a Location" +msgstr "" + +#: templates/dcim/location.html:98 templates/dcim/site.html:164 +msgid "Add a Device" +msgstr "" + +#: templates/dcim/manufacturer.html:16 +msgid "Add Device Type" +msgstr "" + +#: templates/dcim/manufacturer.html:21 +msgid "Add Module Type" +msgstr "" + +#: templates/dcim/powerfeed.html:56 +msgid "Connected Device" +msgstr "" + +#: templates/dcim/powerfeed.html:66 +msgid "Utilization (Allocated" +msgstr "" + +#: templates/dcim/powerfeed.html:85 +msgid "Electrical Characteristics" +msgstr "" + +#: templates/dcim/powerfeed.html:95 +msgctxt "Abbreviation for volts" +msgid "V" +msgstr "" + +#: templates/dcim/powerfeed.html:99 +msgctxt "Abbreviation for amperes" +msgid "A" +msgstr "" + +#: templates/dcim/poweroutlet.html:51 +msgid "Feed Leg" +msgstr "" + +#: templates/dcim/powerpanel.html:77 +msgid "Add Power Feeds" +msgstr "" + +#: templates/dcim/powerport.html:47 +msgid "Maximum Draw" +msgstr "" + +#: templates/dcim/powerport.html:51 +msgid "Allocated Draw" +msgstr "" + +#: templates/dcim/rack.html:73 +msgid "Space Utilization" +msgstr "" + +#: templates/dcim/rack.html:103 +msgid "descending" +msgstr "" + +#: templates/dcim/rack.html:103 +msgid "ascending" +msgstr "" + +#: templates/dcim/rack.html:106 +msgid "Starting Unit" +msgstr "" + +#: templates/dcim/rack.html:132 +msgid "Mounting Depth" +msgstr "" + +#: templates/dcim/rack.html:142 +msgid "Rack Weight" +msgstr "" + +#: templates/dcim/rack.html:152 templates/dcim/rack_edit.html:67 +msgid "Maximum Weight" +msgstr "" + +#: templates/dcim/rack.html:162 +msgid "Total Weight" +msgstr "" + +#: templates/dcim/rack.html:180 templates/dcim/rack_elevation_list.html:16 +msgid "Images and Labels" +msgstr "" + +#: templates/dcim/rack.html:181 templates/dcim/rack_elevation_list.html:17 +msgid "Images only" +msgstr "" + +#: templates/dcim/rack.html:182 templates/dcim/rack_elevation_list.html:18 +msgid "Labels only" +msgstr "" + +#: templates/dcim/rack/reservations.html:9 +msgid "Add reservation" +msgstr "" + +#: templates/dcim/rack_edit.html:21 +msgid "Inventory Control" +msgstr "" + +#: templates/dcim/rack_edit.html:45 +msgid "Outer Dimensions" +msgstr "" + +#: templates/dcim/rack_edit.html:56 templates/dcim/rack_edit.html:71 +msgid "Unit" +msgstr "" + +#: templates/dcim/rack_elevation_list.html:12 +msgid "View List" +msgstr "" + +#: templates/dcim/rack_elevation_list.html:27 +msgid "Sort By" +msgstr "" + +#: templates/dcim/rack_elevation_list.html:77 +msgid "No Racks Found" +msgstr "" + +#: templates/dcim/rack_list.html:8 +msgid "View Elevations" +msgstr "" + +#: templates/dcim/rackreservation.html:47 +msgid "Reservation Details" +msgstr "" + +#: templates/dcim/rackrole.html:10 +msgid "Add Rack" +msgstr "" + +#: templates/dcim/rearport.html:53 +msgid "Positions" +msgstr "" + +#: templates/dcim/region.html:17 templates/dcim/sitegroup.html:17 +msgid "Add Site" +msgstr "" + +#: templates/dcim/region.html:56 +msgid "Child Regions" +msgstr "" + +#: templates/dcim/region.html:64 +msgid "Add Region" +msgstr "" + +#: templates/dcim/site.html:69 +msgid "Facility" +msgstr "" + +#: templates/dcim/site.html:77 +msgid "Time Zone" +msgstr "" + +#: templates/dcim/site.html:80 +msgid "UTC" +msgstr "" + +#: templates/dcim/site.html:81 +msgid "Site time" +msgstr "" + +#: templates/dcim/site.html:88 +msgid "Physical Address" +msgstr "" + +#: templates/dcim/site.html:94 +msgid "Map" +msgstr "" + +#: templates/dcim/site.html:105 +msgid "Shipping Address" +msgstr "" + +#: templates/dcim/sitegroup.html:56 templates/tenancy/contactgroup.html:49 +#: templates/tenancy/tenantgroup.html:58 +#: templates/wireless/wirelesslangroup.html:56 +msgid "Child Groups" +msgstr "" + +#: templates/dcim/sitegroup.html:64 +msgid "Add Site Group" +msgstr "" + +#: templates/dcim/trace/attachment.html:5 +#: templates/extras/exporttemplate.html:37 +msgid "Attachment" +msgstr "" + +#: templates/dcim/virtualchassis.html:86 +msgid "Add Member" +msgstr "" + +#: templates/dcim/virtualchassis_add.html:18 +msgid "Member Devices" +msgstr "" + +#: templates/dcim/virtualchassis_add_member.html:6 +#, python-format +msgid "Add New Member to Virtual Chassis %(virtual_chassis)s" +msgstr "" + +#: templates/dcim/virtualchassis_add_member.html:17 +msgid "Add New Member" +msgstr "" + +#: templates/dcim/virtualchassis_add_member.html:25 +msgid "Add Another" +msgstr "" + +#: templates/dcim/virtualchassis_edit.html:7 +#, python-format +msgid "Editing Virtual Chassis %(name)s" +msgstr "" + +#: templates/dcim/virtualchassis_edit.html:54 +msgid "Rack/Unit" +msgstr "" + +#: templates/dcim/virtualchassis_remove_member.html:5 +msgid "Remove Virtual Chassis Member" +msgstr "" + +#: templates/dcim/virtualchassis_remove_member.html:9 +#, python-format +msgid "" +"Are you sure you want to remove %(device)s from virtual " +"chassis %(name)s?" +msgstr "" + +#: templates/dcim/virtualdevicecontext.html:29 templates/ipam/l2vpn.html:19 +msgid "Identifier" +msgstr "" + +#: templates/exceptions/import_error.html:6 +msgid "" +"A module import error occurred during this request. Common causes include " +"the following:" +msgstr "" + +#: templates/exceptions/import_error.html:10 +msgid "Missing required packages" +msgstr "" + +#: templates/exceptions/import_error.html:11 +msgid "" +"This installation of NetBox might be missing one or more required Python " +"packages. These packages are listed in requirements.txt and " +"local_requirements.txt, and are normally installed as part of " +"the installation or upgrade process. To verify installed packages, run " +"pip freeze from the console and compare the output to the list " +"of required packages." +msgstr "" + +#: templates/exceptions/import_error.html:20 +msgid "WSGI service not restarted after upgrade" +msgstr "" + +#: templates/exceptions/import_error.html:21 +msgid "" +"If this installation has recently been upgraded, check that the WSGI service " +"(e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code " +"is running." +msgstr "" + +#: templates/exceptions/permission_error.html:6 +msgid "" +"A file permission error was detected while processing this request. Common " +"causes include the following:" +msgstr "" + +#: templates/exceptions/permission_error.html:10 +msgid "Insufficient write permission to the media root" +msgstr "" + +#: templates/exceptions/permission_error.html:11 +#, python-format +msgid "" +"The configured media root is %(media_root)s. Ensure that the " +"user NetBox runs as has access to write files to all locations within this " +"path." +msgstr "" + +#: templates/exceptions/programming_error.html:6 +msgid "" +"A database programming error was detected while processing this request. " +"Common causes include the following:" +msgstr "" + +#: templates/exceptions/programming_error.html:10 +msgid "Database migrations missing" +msgstr "" + +#: templates/exceptions/programming_error.html:11 +msgid "" +"When upgrading to a new NetBox release, the upgrade script must be run to " +"apply any new database migrations. You can run migrations manually by " +"executing python3 manage.py migrate from the command line." +msgstr "" + +#: templates/exceptions/programming_error.html:18 +msgid "Unsupported PostgreSQL version" +msgstr "" + +#: templates/exceptions/programming_error.html:19 +msgid "" +"Ensure that PostgreSQL version 12 or later is in use. You can check this by " +"connecting to the database using NetBox's credentials and issuing a query " +"for SELECT VERSION()." +msgstr "" + +#: templates/extras/admin/plugins_list.html:4 +#: templates/extras/admin/plugins_list.html:9 +#: templates/extras/admin/plugins_list.html:13 +msgid "Installed Plugins" +msgstr "" + +#: templates/extras/admin/plugins_list.html:23 +msgid "Package Name" +msgstr "" + +#: templates/extras/admin/plugins_list.html:24 +msgid "Author" +msgstr "" + +#: templates/extras/admin/plugins_list.html:25 +msgid "Author Email" +msgstr "" + +#: templates/extras/admin/plugins_list.html:27 +msgid "Version" +msgstr "" + +#: templates/extras/configcontext.html:46 +#: templates/extras/configtemplate.html:38 +#: templates/extras/exporttemplate.html:57 +msgid "The data file associated with this object has been deleted" +msgstr "" + +#: templates/extras/configcontext.html:55 +#: templates/extras/configtemplate.html:47 +#: templates/extras/exporttemplate.html:66 +msgid "Data Synced" +msgstr "" + +#: templates/extras/configcontext_list.html:7 +#: templates/extras/configtemplate_list.html:7 +#: templates/extras/exporttemplate_list.html:7 +msgid "Sync Data" +msgstr "" + +#: templates/extras/configrevision.html:47 +msgid "Default unit height" +msgstr "" + +#: templates/extras/configrevision.html:51 +msgid "Default unit width" +msgstr "" + +#: templates/extras/configrevision.html:63 +msgid "Default voltage" +msgstr "" + +#: templates/extras/configrevision.html:67 +msgid "Default amperage" +msgstr "" + +#: templates/extras/configrevision.html:71 +msgid "Default max utilization" +msgstr "" + +#: templates/extras/configrevision.html:83 +msgid "Enforce global unique" +msgstr "" + +#: templates/extras/configrevision.html:135 +msgid "Paginate count" +msgstr "" + +#: templates/extras/configrevision.html:139 +msgid "Max page size" +msgstr "" + +#: templates/extras/configrevision.html:163 +msgid "Default user preferences" +msgstr "" + +#: templates/extras/configrevision.html:187 +msgid "Job retention" +msgstr "" + +#: templates/extras/configrevision.html:199 +msgid "Comment" +msgstr "" + +#: templates/extras/configrevision_restore.html:8 +#: templates/extras/configrevision_restore.html:43 +#: templates/extras/configrevision_restore.html:79 +msgid "Restore" +msgstr "" + +#: templates/extras/configrevision_restore.html:21 +msgid "Config revisions" +msgstr "" + +#: templates/extras/configrevision_restore.html:54 +msgid "Parameter" +msgstr "" + +#: templates/extras/configrevision_restore.html:55 +msgid "Current Value" +msgstr "" + +#: templates/extras/configrevision_restore.html:56 +msgid "New Value" +msgstr "" + +#: templates/extras/configrevision_restore.html:66 +msgid "Changed" +msgstr "" + +#: templates/extras/configtemplate.html:58 +msgid "Environment Parameters" +msgstr "" + +#: templates/extras/configtemplate.html:69 +#: templates/extras/exporttemplate.html:88 +msgid "Template" +msgstr "" + +#: templates/extras/customfield.html:31 templates/extras/customlink.html:22 +msgid "Group Name" +msgstr "" + +#: templates/extras/customfield.html:43 +msgid "Cloneable" +msgstr "" + +#: templates/extras/customfield.html:53 +msgid "Default Value" +msgstr "" + +#: templates/extras/customfield.html:64 +msgid "Search Weight" +msgstr "" + +#: templates/extras/customfield.html:74 +msgid "Filter Logic" +msgstr "" + +#: templates/extras/customfield.html:78 +msgid "Display Weight" +msgstr "" + +#: templates/extras/customfield.html:104 +msgid "Validation Rules" +msgstr "" + +#: templates/extras/customfield.html:108 +msgid "Minimum Value" +msgstr "" + +#: templates/extras/customfield.html:112 +msgid "Maximum Value" +msgstr "" + +#: templates/extras/customfield.html:116 +msgid "Regular Expression" +msgstr "" + +#: templates/extras/customlink.html:30 +msgid "Button Class" +msgstr "" + +#: templates/extras/customlink.html:41 templates/extras/exporttemplate.html:73 +#: templates/extras/savedfilter.html:41 templates/extras/webhook.html:102 +msgid "Assigned Models" +msgstr "" + +#: templates/extras/customlink.html:57 +msgid "Link Text" +msgstr "" + +#: templates/extras/customlink.html:65 +msgid "Link URL" +msgstr "" + +#: templates/extras/dashboard/reset.html:4 templates/home.html:63 +msgid "Reset Dashboard" +msgstr "" + +#: templates/extras/dashboard/reset.html:8 +msgid "" +"This will remove all configured widgets and restore the " +"default dashboard configuration." +msgstr "" + +#: templates/extras/dashboard/reset.html:13 +msgid "" +"This change affects only your dashboard, and will not impact other " +"users." +msgstr "" + +#: templates/extras/dashboard/widget_add.html:7 +msgid "Add a Widget" +msgstr "" + +#: templates/extras/dashboard/widgets/bookmarks.html:14 +msgid "No bookmarks have been added yet." +msgstr "" + +#: templates/extras/dashboard/widgets/objectcounts.html:15 +msgid "No permission" +msgstr "" + +#: templates/extras/dashboard/widgets/objectlist.html:6 +msgid "No permission to view this content" +msgstr "" + +#: templates/extras/dashboard/widgets/objectlist.html:10 +msgid "Unable to load content. Invalid view name" +msgstr "" + +#: templates/extras/dashboard/widgets/rssfeed.html:12 +msgid "No content found" +msgstr "" + +#: templates/extras/dashboard/widgets/rssfeed.html:18 +msgid "There was a problem fetching the RSS feed" +msgstr "" + +#: templates/extras/dashboard/widgets/rssfeed.html:21 +msgid "HTTP" +msgstr "" + +#: templates/extras/exporttemplate.html:29 +msgid "MIME Type" +msgstr "" + +#: templates/extras/exporttemplate.html:33 +msgid "File Extension" +msgstr "" + +#: templates/extras/htmx/report_result.html:9 +#: templates/extras/htmx/script_result.html:10 +msgid "Scheduled for" +msgstr "" + +#: templates/extras/htmx/report_result.html:14 +#: templates/extras/htmx/script_result.html:15 +msgid "Duration" +msgstr "" + +#: templates/extras/htmx/report_result.html:20 +msgid "Report Methods" +msgstr "" + +#: templates/extras/htmx/report_result.html:38 +msgid "Report Results" +msgstr "" + +#: templates/extras/htmx/report_result.html:44 +#: templates/extras/htmx/script_result.html:26 +msgid "Level" +msgstr "" + +#: templates/extras/htmx/report_result.html:46 +#: templates/extras/htmx/script_result.html:27 +msgid "Message" +msgstr "" + +#: templates/extras/htmx/script_result.html:21 +msgid "Script Log" +msgstr "" + +#: templates/extras/htmx/script_result.html:25 +msgid "Line" +msgstr "" + +#: templates/extras/htmx/script_result.html:38 +msgid "No log output" +msgstr "" + +#: templates/extras/htmx/script_result.html:46 +msgid "Exec Time" +msgstr "" + +#: templates/extras/htmx/script_result.html:46 +msgctxt "Unit of time" +msgid "seconds" +msgstr "" + +#: templates/extras/htmx/script_result.html:50 +msgid "Output" +msgstr "" + +#: templates/extras/inc/result_pending.html:4 +msgid "Loading" +msgstr "" + +#: templates/extras/inc/result_pending.html:6 +msgid "Results pending" +msgstr "" + +#: templates/extras/journalentry.html:16 +msgid "Journal Entry" +msgstr "" + +#: templates/extras/object_changelog.html:15 +#: templates/extras/objectchange_list.html:9 +msgid "Change log retention" +msgstr "" + +#: templates/extras/object_changelog.html:15 +#: templates/extras/objectchange_list.html:9 +msgid "days" +msgstr "" + +#: templates/extras/object_changelog.html:15 +#: templates/extras/objectchange_list.html:9 +msgid "Indefinite" +msgstr "" + +#: templates/extras/object_configcontext.html:11 +msgid "Rendered Context" +msgstr "" + +#: templates/extras/object_configcontext.html:22 +msgid "Local Context" +msgstr "" + +#: templates/extras/object_configcontext.html:34 +msgid "The local config context overwrites all source contexts" +msgstr "" + +#: templates/extras/object_configcontext.html:40 +msgid "Source Contexts" +msgstr "" + +#: templates/extras/object_journal.html:18 +msgid "New Journal Entry" +msgstr "" + +#: templates/extras/objectchange.html:29 +#: templates/users/objectpermission.html:45 +msgid "Change" +msgstr "" + +#: templates/extras/objectchange.html:84 +msgid "Difference" +msgstr "" + +#: templates/extras/objectchange.html:87 +msgid "Previous" +msgstr "" + +#: templates/extras/objectchange.html:90 +msgid "Next" +msgstr "" + +#: templates/extras/objectchange.html:98 +msgid "Object Created" +msgstr "" + +#: templates/extras/objectchange.html:100 +msgid "Object Deleted" +msgstr "" + +#: templates/extras/objectchange.html:102 +msgid "No Changes" +msgstr "" + +#: templates/extras/objectchange.html:117 +msgid "Pre-Change Data" +msgstr "" + +#: templates/extras/objectchange.html:126 +msgid "Warning: Comparing non-atomic change to previous change record" +msgstr "" + +#: templates/extras/objectchange.html:136 +msgid "Post-Change Data" +msgstr "" + +#: templates/extras/objectchange.html:157 +#, python-format +msgid "See All %(count)s Changes" +msgstr "" + +#: templates/extras/report.html:14 +msgid "This report is invalid and cannot be run." +msgstr "" + +#: templates/extras/report.html:23 templates/extras/report_list.html:88 +msgid "Run Again" +msgstr "" + +#: templates/extras/report.html:25 templates/extras/report_list.html:90 +msgid "Run Report" +msgstr "" + +#: templates/extras/report.html:36 +msgid "Last run" +msgstr "" + +#: templates/extras/report/base.html:30 +msgid "Report" +msgstr "" + +#: templates/extras/report_list.html:48 templates/extras/script_list.html:54 +msgid "Last Run" +msgstr "" + +#: templates/extras/report_list.html:70 templates/extras/script_list.html:77 +msgid "Never" +msgstr "" + +#: templates/extras/report_list.html:75 +msgid "Report has no test methods" +msgstr "" + +#: templates/extras/report_list.html:76 +msgid "Invalid" +msgstr "" + +#: templates/extras/report_list.html:125 +msgid "No Reports Found" +msgstr "" + +#: templates/extras/report_list.html:128 +#, python-format +msgid "" +"Get started by creating a report from " +"an uploaded file or data source." +msgstr "" + +#: templates/extras/script.html:13 +msgid "You do not have permission to run scripts" +msgstr "" + +#: templates/extras/script.html:37 +msgid "Run Script" +msgstr "" + +#: templates/extras/script/base.html:29 +msgid "Script" +msgstr "" + +#: templates/extras/script_list.html:44 +#, python-format +msgid "" +"Script file at %(file_path)s could not be loaded." +msgstr "" + +#: templates/extras/script_list.html:91 +msgid "No Scripts Found" +msgstr "" + +#: templates/extras/script_list.html:94 +#, python-format +msgid "" +"Get started by creating a script from " +"an uploaded file or data source." +msgstr "" + +#: templates/extras/script_result.html:42 +msgid "Log" +msgstr "" + +#: templates/extras/tag.html:35 +msgid "Tagged Items" +msgstr "" + +#: templates/extras/tag.html:47 +msgid "Allowed Object Types" +msgstr "" + +#: templates/extras/tag.html:56 +msgid "Any" +msgstr "" + +#: templates/extras/tag.html:63 +msgid "Tagged Item Types" +msgstr "" + +#: templates/extras/tag.html:89 +msgid "Tagged Objects" +msgstr "" + +#: templates/extras/webhook.html:45 +msgid "Job start" +msgstr "" + +#: templates/extras/webhook.html:49 +msgid "Job end" +msgstr "" + +#: templates/extras/webhook.html:62 +msgid "HTTP Method" +msgstr "" + +#: templates/extras/webhook.html:70 +msgid "HTTP Content Type" +msgstr "" + +#: templates/extras/webhook.html:87 +msgid "SSL Verification" +msgstr "" + +#: templates/extras/webhook.html:128 +msgid "Additional Headers" +msgstr "" + +#: templates/extras/webhook.html:140 +msgid "Body Template" +msgstr "" + +#: templates/generic/bulk_add_component.html:15 +msgid "Bulk Creation" +msgstr "" + +#: templates/generic/bulk_add_component.html:20 +#: templates/generic/bulk_edit.html:28 +msgid "Selected Objects" +msgstr "" + +#: templates/generic/bulk_add_component.html:46 +msgid "to Add" +msgstr "" + +#: templates/generic/bulk_delete.html:24 +msgid "Confirm Bulk Deletion" +msgstr "" + +#: templates/generic/bulk_delete.html:26 +msgctxt "Noun" +msgid "Warning" +msgstr "" + +#: templates/generic/bulk_delete.html:27 +#, python-format +msgid "" +"The following operation will delete %(count)s " +"%(type_plural)s. Please carefully review the objects to be deleted and " +"confirm below." +msgstr "" + +#: templates/generic/bulk_edit.html:16 templates/generic/object_edit.html:17 +msgid "Editing" +msgstr "" + +#: templates/generic/bulk_edit.html:23 +msgid "Bulk Edit" +msgstr "" + +#: templates/generic/bulk_edit.html:124 templates/generic/bulk_rename.html:42 +msgid "Apply" +msgstr "" + +#: templates/generic/bulk_import.html:14 +msgid "Bulk Import" +msgstr "" + +#: templates/generic/bulk_import.html:20 +msgid "Direct Import" +msgstr "" + +#: templates/generic/bulk_import.html:25 +msgid "Upload File" +msgstr "" + +#: templates/generic/bulk_import.html:51 templates/generic/bulk_import.html:73 +#: templates/generic/bulk_import.html:95 +msgid "Submit" +msgstr "" + +#: templates/generic/bulk_import.html:110 +msgid "Field Options" +msgstr "" + +#: templates/generic/bulk_import.html:117 +msgid "Accessor" +msgstr "" + +#: templates/generic/bulk_import.html:154 +msgid "Import Value" +msgstr "" + +#: templates/generic/bulk_import.html:181 +msgid "Format: YYYY-MM-DD" +msgstr "" + +#: templates/generic/bulk_import.html:183 +msgid "Specify true or false" +msgstr "" + +#: templates/generic/bulk_import.html:195 +msgid "Required fields must be specified for all objects." +msgstr "" + +#: templates/generic/bulk_import.html:201 +#, python-format +msgid "" +"Related objects may be referenced by any unique attribute. For example, " +"%(example)s would identify a VRF by its route distinguisher." +msgstr "" + +#: templates/generic/bulk_remove.html:13 +msgid "Confirm Bulk Removal" +msgstr "" + +#: templates/generic/bulk_remove.html:15 +#, python-format +msgid "" +"Warning: The following operation will remove %(count)s " +"%(obj_type_plural)s from %(parent_obj)s." +msgstr "" + +#: templates/generic/bulk_remove.html:21 +#, python-format +msgid "" +"Please carefully review the %(obj_type_plural)s to be removed and confirm " +"below." +msgstr "" + +#: templates/generic/bulk_remove.html:38 +#, python-format +msgid "Delete these %(count)s %(obj_type_plural)s" +msgstr "" + +#: templates/generic/bulk_rename.html:7 +msgid "Renaming" +msgstr "" + +#: templates/generic/bulk_rename.html:16 +msgid "Current Name" +msgstr "" + +#: templates/generic/bulk_rename.html:17 +msgid "New Name" +msgstr "" + +#: templates/generic/bulk_rename.html:40 +#: utilities/templates/widgets/markdown_input.html:11 +msgid "Preview" +msgstr "" + +#: templates/generic/confirmation_form.html:16 +msgid "Are you sure" +msgstr "" + +#: templates/generic/confirmation_form.html:19 +msgid "Confirm" +msgstr "" + +#: templates/generic/object.html:51 +msgid "ago" +msgstr "" + +#: templates/generic/object_children.html:27 +#: utilities/templates/buttons/bulk_edit.html:4 +msgid "Edit Selected" +msgstr "" + +#: templates/generic/object_children.html:41 +#: utilities/templates/buttons/bulk_delete.html:4 +msgid "Delete Selected" +msgstr "" + +#: templates/generic/object_edit.html:19 +#, python-format +msgid "Add a new %(object_type)s" +msgstr "" + +#: templates/generic/object_edit.html:47 +msgid "View model documentation" +msgstr "" + +#: templates/generic/object_edit.html:48 +msgid "Help" +msgstr "" + +#: templates/generic/object_edit.html:73 +msgid "Create & Add Another" +msgstr "" + +#: templates/generic/object_list.html:48 templates/search.html:13 +msgid "Results" +msgstr "" + +#: templates/generic/object_list.html:54 +msgid "Filters" +msgstr "" + +#: templates/generic/object_list.html:94 +#, python-format +msgid "" +"Select all %(count)s %(object_type_plural)s matching query" +msgstr "" + +#: templates/home.html:12 +msgid "New Release Available" +msgstr "" + +#: templates/home.html:14 +msgid "is available" +msgstr "" + +#: templates/home.html:17 +msgctxt "Document title" +msgid "Upgrade Instructions" +msgstr "" + +#: templates/home.html:37 +msgid "Unlock Dashboard" +msgstr "" + +#: templates/home.html:46 +msgid "Lock Dashboard" +msgstr "" + +#: templates/home.html:57 +msgid "Add Widget" +msgstr "" + +#: templates/home.html:60 +msgid "Save Layout" +msgstr "" + +#: templates/htmx/delete_form.html:7 +msgid "Confirm Deletion" +msgstr "" + +#: templates/htmx/delete_form.html:11 +#, python-format +msgid "" +"Are you sure you want to delete " +"%(object_type)s %(object)s?" +msgstr "" + +#: templates/htmx/object_selector.html:5 +msgid "Select" +msgstr "" + +#: templates/inc/filter_list.html:50 +#: utilities/templates/helpers/table_config_form.html:39 +msgid "Reset" +msgstr "" + +#: templates/inc/missing_prerequisites.html:7 +#, python-format +msgid "" +"Before you can add a %(model)s you must first create a " +"%(prerequisite_model)s." +msgstr "" + +#: templates/inc/paginator.html:38 templates/inc/paginator_htmx.html:53 +msgid "Per Page" +msgstr "" + +#: templates/inc/paginator.html:49 templates/inc/paginator_htmx.html:69 +#, python-format +msgid "Showing %(start)s-%(end)s of %(total)s" +msgstr "" + +#: templates/inc/panels/image_attachments.html:10 +msgid "Attach an image" +msgstr "" + +#: templates/inc/panels/related_objects.html:5 +msgid "Related Objects" +msgstr "" + +#: templates/inc/panels/tags.html:11 +msgid "No tags assigned" +msgstr "" + +#: templates/inc/profile_button.html:12 templates/inc/profile_button.html:62 +msgid "Dark Mode" +msgstr "" + +#: templates/inc/profile_button.html:45 +msgid "Log Out" +msgstr "" + +#: templates/inc/profile_button.html:53 +msgid "Log In" +msgstr "" + +#: templates/inc/sync_warning.html:7 +msgid "Data is out of sync with upstream file" +msgstr "" + +#: templates/inc/table_controls_htmx.html:16 +#: templates/inc/table_controls_htmx.html:18 +msgid "Configure Table" +msgstr "" + +#: templates/ipam/aggregate.html:15 templates/ipam/ipaddress.html:17 +#: templates/ipam/iprange.html:16 templates/ipam/prefix.html:15 +msgid "Family" +msgstr "" + +#: templates/ipam/aggregate.html:40 +msgid "Date Added" +msgstr "" + +#: templates/ipam/aggregate/prefixes.html:8 +#: templates/ipam/prefix/prefixes.html:8 templates/ipam/role.html:10 +msgid "Add Prefix" +msgstr "" + +#: templates/ipam/asn.html:24 +msgid "AS Number" +msgstr "" + +#: templates/ipam/fhrpgroup.html:55 +msgid "Authentication Type" +msgstr "" + +#: templates/ipam/fhrpgroup.html:59 +msgid "Authentication Key" +msgstr "" + +#: templates/ipam/fhrpgroup.html:72 +msgid "Virtual IP Addresses" +msgstr "" + +#: templates/ipam/fhrpgroupassignment_edit.html:8 +msgid "FHRP Group Assignment" +msgstr "" + +#: templates/ipam/inc/ipaddress_edit_header.html:19 +msgid "Assign IP" +msgstr "" + +#: templates/ipam/inc/ipaddress_edit_header.html:28 +msgid "Bulk Create" +msgstr "" + +#: templates/ipam/inc/panels/fhrp_groups.html:12 +msgid "Virtual IPs" +msgstr "" + +#: templates/ipam/inc/panels/fhrp_groups.html:52 +msgid "Create Group" +msgstr "" + +#: templates/ipam/inc/panels/fhrp_groups.html:57 +msgid "Assign Group" +msgstr "" + +#: templates/ipam/inc/toggle_available.html:7 +msgid "Show Assigned" +msgstr "" + +#: templates/ipam/inc/toggle_available.html:10 +msgid "Show Available" +msgstr "" + +#: templates/ipam/inc/toggle_available.html:13 +msgid "Show All" +msgstr "" + +#: templates/ipam/ipaddress.html:26 templates/ipam/iprange.html:48 +#: templates/ipam/prefix.html:24 +msgid "Global" +msgstr "" + +#: templates/ipam/ipaddress.html:88 +msgid "NAT (outside)" +msgstr "" + +#: templates/ipam/ipaddress_assign.html:8 +msgid "Assign an IP Address" +msgstr "" + +#: templates/ipam/ipaddress_assign.html:23 +msgid "Select IP Address" +msgstr "" + +#: templates/ipam/ipaddress_assign.html:39 +msgid "Search Results" +msgstr "" + +#: templates/ipam/ipaddress_bulk_add.html:6 +msgid "Bulk Add IP Addresses" +msgstr "" + +#: templates/ipam/ipaddress_edit.html:35 +msgid "Interface Assignment" +msgstr "" + +#: templates/ipam/ipaddress_edit.html:74 +msgid "NAT IP (Inside" +msgstr "" + +#: templates/ipam/iprange.html:20 +msgid "Starting Address" +msgstr "" + +#: templates/ipam/iprange.html:24 +msgid "Ending Address" +msgstr "" + +#: templates/ipam/iprange.html:36 templates/ipam/prefix.html:104 +msgid "Marked fully utilized" +msgstr "" + +#: templates/ipam/l2vpn.html:11 templates/ipam/l2vpntermination.html:10 +msgid "L2VPN Attributes" +msgstr "" + +#: templates/ipam/l2vpn.html:65 +msgid "Add a Termination" +msgstr "" + +#: templates/ipam/l2vpntermination_edit.html:9 +msgid "L2VPN Termination" +msgstr "" + +#: templates/ipam/prefix.html:112 +msgid "Child IPs" +msgstr "" + +#: templates/ipam/prefix.html:120 +msgid "Available IPs" +msgstr "" + +#: templates/ipam/prefix.html:132 +msgid "First available IP" +msgstr "" + +#: templates/ipam/prefix.html:151 +msgid "Addressing Details" +msgstr "" + +#: templates/ipam/prefix.html:181 +msgid "Prefix Details" +msgstr "" + +#: templates/ipam/prefix.html:187 +msgid "Network Address" +msgstr "" + +#: templates/ipam/prefix.html:191 +msgid "Network Mask" +msgstr "" + +#: templates/ipam/prefix.html:195 +msgid "Wildcard Mask" +msgstr "" + +#: templates/ipam/prefix.html:199 +msgid "Broadcast Address" +msgstr "" + +#: templates/ipam/prefix/ip_ranges.html:7 +msgid "Add IP Range" +msgstr "" + +#: templates/ipam/prefix_list.html:7 +msgid "Hide Depth Indicators" +msgstr "" + +#: templates/ipam/prefix_list.html:11 +msgid "Max Depth" +msgstr "" + +#: templates/ipam/prefix_list.html:28 +msgid "Max Length" +msgstr "" + +#: templates/ipam/rir.html:10 +msgid "Add Aggregate" +msgstr "" + +#: templates/ipam/routetarget.html:10 +msgid "Route Target" +msgstr "" + +#: templates/ipam/routetarget.html:40 +msgid "Importing VRFs" +msgstr "" + +#: templates/ipam/routetarget.html:49 +msgid "Exporting VRFs" +msgstr "" + +#: templates/ipam/routetarget.html:60 +msgid "Importing L2VPNs" +msgstr "" + +#: templates/ipam/routetarget.html:69 +msgid "Exporting L2VPNs" +msgstr "" + +#: templates/ipam/service.html:22 templates/ipam/service_create.html:8 +#: templates/ipam/service_edit.html:8 +msgid "Service" +msgstr "" + +#: templates/ipam/service_create.html:43 +msgid "From Template" +msgstr "" + +#: templates/ipam/service_create.html:48 +msgid "Custom" +msgstr "" + +#: templates/ipam/service_edit.html:37 +msgid "Port(s)" +msgstr "" + +#: templates/ipam/vlan.html:95 +msgid "Add a Prefix" +msgstr "" + +#: templates/ipam/vlangroup.html:18 +msgid "Add VLAN" +msgstr "" + +#: templates/ipam/vlangroup.html:43 +msgid "Permitted VIDs" +msgstr "" + +#: templates/ipam/vrf.html:19 +msgid "Route Distinguisher" +msgstr "" + +#: templates/ipam/vrf.html:32 +msgid "Unique IP Space" +msgstr "" + +#: templates/login.html:20 +#: utilities/templates/form_helpers/render_errors.html:7 +msgid "Errors" +msgstr "" + +#: templates/login.html:48 +msgid "Sign In" +msgstr "" + +#: templates/login.html:54 +msgid "Or use a single sign-on (SSO) provider" +msgstr "" + +#: templates/login.html:68 +msgid "Toggle Color Mode" +msgstr "" + +#: templates/media_failure.html:7 +msgid "Static Media Failure - NetBox" +msgstr "" + +#: templates/media_failure.html:21 +msgid "Static Media Failure" +msgstr "" + +#: templates/media_failure.html:23 +msgid "The following static media file failed to load" +msgstr "" + +#: templates/media_failure.html:26 +msgid "Check the following" +msgstr "" + +#: templates/media_failure.html:29 +msgid "" +"manage.py collectstatic was run during the most recent upgrade. " +"This installs the most recent iteration of each static file into the static " +"root path." +msgstr "" + +#: templates/media_failure.html:35 +#, python-format +msgid "" +"The HTTP service (e.g. nginx or Apache) is configured to serve files from " +"the STATIC_ROOT path. Refer to the " +"installation documentation for further guidance." +msgstr "" + +#: templates/media_failure.html:47 +#, python-format +msgid "" +"The file %(filename)s exists in the static root directory and " +"is readable by the HTTP server." +msgstr "" + +#: templates/media_failure.html:55 +#, python-format +msgid "" +"Click here to attempt loading NetBox again." +msgstr "" + +#: templates/tenancy/contact.html:18 tenancy/filtersets.py:123 +#: tenancy/forms/bulk_edit.py:136 tenancy/forms/filtersets.py:103 +#: tenancy/forms/forms.py:56 tenancy/forms/model_forms.py:112 +#: tenancy/forms/model_forms.py:135 tenancy/tables/contacts.py:98 +msgid "Contact" +msgstr "" + +#: templates/tenancy/contact.html:30 tenancy/forms/bulk_edit.py:98 +msgid "Title" +msgstr "" + +#: templates/tenancy/contact.html:34 tenancy/forms/bulk_edit.py:103 +#: tenancy/tables/contacts.py:64 +msgid "Phone" +msgstr "" + +#: templates/tenancy/contact.html:86 tenancy/tables/contacts.py:73 +msgid "Assignments" +msgstr "" + +#: templates/tenancy/contactassignment_edit.html:12 +msgid "Contact Assignment" +msgstr "" + +#: templates/tenancy/contactgroup.html:19 tenancy/forms/forms.py:66 +#: tenancy/forms/model_forms.py:79 +msgid "Contact Group" +msgstr "" + +#: templates/tenancy/contactgroup.html:57 +msgid "Add Contact Group" +msgstr "" + +#: templates/tenancy/contactrole.html:15 tenancy/filtersets.py:128 +#: tenancy/forms/forms.py:61 tenancy/forms/model_forms.py:93 +msgid "Contact Role" +msgstr "" + +#: templates/tenancy/object_contacts.html:9 +msgid "Add a contact" +msgstr "" + +#: templates/tenancy/tenantgroup.html:17 +msgid "Add Tenant" +msgstr "" + +#: templates/tenancy/tenantgroup.html:27 tenancy/forms/model_forms.py:34 +#: tenancy/tables/columns.py:51 tenancy/tables/columns.py:61 +msgid "Tenant Group" +msgstr "" + +#: templates/tenancy/tenantgroup.html:66 +msgid "Add Tenant Group" +msgstr "" + +#: templates/users/group.html:37 templates/users/user.html:61 +msgid "Assigned Permissions" +msgstr "" + +#: templates/users/objectpermission.html:6 +#: templates/users/objectpermission.html:14 users/forms/filtersets.py:69 +msgid "Permission" +msgstr "" + +#: templates/users/objectpermission.html:33 users/forms/filtersets.py:70 +#: users/forms/model_forms.py:321 +msgid "Actions" +msgstr "" + +#: templates/users/objectpermission.html:37 +msgid "View" +msgstr "" + +#: templates/users/objectpermission.html:56 users/forms/model_forms.py:324 +msgid "Constraints" +msgstr "" + +#: templates/users/objectpermission.html:76 +msgid "Assigned Users" +msgstr "" + +#: templates/users/user.html:38 +msgid "Staff" +msgstr "" + +#: templates/virtualization/cluster.html:56 +msgid "Allocated Resources" +msgstr "" + +#: templates/virtualization/cluster.html:60 +#: templates/virtualization/virtualmachine.html:128 +msgid "Virtual CPUs" +msgstr "" + +#: templates/virtualization/cluster.html:64 +#: templates/virtualization/virtualmachine.html:132 +msgid "Memory" +msgstr "" + +#: templates/virtualization/cluster.html:74 +#: templates/virtualization/virtualmachine.html:142 +msgid "Disk Space" +msgstr "" + +#: templates/virtualization/cluster.html:77 +#: templates/virtualization/virtualmachine.html:145 +msgctxt "Abbreviation for gigabyte" +msgid "GB" +msgstr "" + +#: templates/virtualization/cluster/base.html:18 +msgid "Add Virtual Machine" +msgstr "" + +#: templates/virtualization/cluster/base.html:24 +msgid "Assign Device" +msgstr "" + +#: templates/virtualization/cluster/devices.html:10 +msgid "Remove Selected" +msgstr "" + +#: templates/virtualization/cluster_add_devices.html:9 +#, python-format +msgid "Add Device to Cluster %(cluster)s" +msgstr "" + +#: templates/virtualization/cluster_add_devices.html:23 +msgid "Device Selection" +msgstr "" + +#: templates/virtualization/cluster_add_devices.html:31 +msgid "Add Devices" +msgstr "" + +#: templates/virtualization/clustergroup.html:10 +#: templates/virtualization/clustertype.html:10 +msgid "Add Cluster" +msgstr "" + +#: templates/virtualization/clustergroup.html:20 +#: virtualization/forms/model_forms.py:50 +msgid "Cluster Group" +msgstr "" + +#: templates/virtualization/clustertype.html:20 +#: templates/virtualization/virtualmachine.html:111 +#: virtualization/forms/model_forms.py:34 +msgid "Cluster Type" +msgstr "" + +#: templates/virtualization/virtualmachine.html:124 +#: virtualization/forms/bulk_edit.py:187 +#: virtualization/forms/model_forms.py:225 +msgid "Resources" +msgstr "" + +#: templates/wireless/inc/authentication_attrs.html:13 +msgid "Cipher" +msgstr "" + +#: templates/wireless/inc/authentication_attrs.html:17 +msgid "PSK" +msgstr "" + +#: templates/wireless/inc/authentication_attrs.html:21 +msgid "Show Secret" +msgstr "" + +#: templates/wireless/inc/wirelesslink_interface.html:35 +#: templates/wireless/inc/wirelesslink_interface.html:45 +msgctxt "Abbreviation for megahertz" +msgid "MHz" +msgstr "" + +#: templates/wireless/wirelesslan.html:11 wireless/forms/model_forms.py:54 +msgid "Wireless LAN" +msgstr "" + +#: templates/wireless/wirelesslan.html:59 +msgid "Attached Interfaces" +msgstr "" + +#: templates/wireless/wirelesslangroup.html:17 +msgid "Add Wireless LAN" +msgstr "" + +#: templates/wireless/wirelesslangroup.html:26 wireless/forms/model_forms.py:27 +msgid "Wireless LAN Group" +msgstr "" + +#: templates/wireless/wirelesslangroup.html:64 +msgid "Add Wireless LAN Group" +msgstr "" + +#: templates/wireless/wirelesslink.html:16 +msgid "Link Properties" +msgstr "" + +#: tenancy/choices.py:19 +msgid "Tertiary" +msgstr "" + +#: tenancy/choices.py:20 +msgid "Inactive" +msgstr "" + +#: tenancy/filtersets.py:30 tenancy/filtersets.py:56 +msgid "Contact group (ID)" +msgstr "" + +#: tenancy/filtersets.py:36 tenancy/filtersets.py:63 +msgid "Contact group (slug)" +msgstr "" + +#: tenancy/filtersets.py:92 +msgid "Contact (ID)" +msgstr "" + +#: tenancy/filtersets.py:96 +msgid "Contact role (ID)" +msgstr "" + +#: tenancy/filtersets.py:102 +msgid "Contact role (slug)" +msgstr "" + +#: tenancy/filtersets.py:134 +msgid "Contact group" +msgstr "" + +#: tenancy/filtersets.py:145 tenancy/filtersets.py:164 +msgid "Tenant group (ID)" +msgstr "" + +#: tenancy/filtersets.py:197 +msgid "Tenant Group (ID)" +msgstr "" + +#: tenancy/filtersets.py:204 +msgid "Tenant Group (slug)" +msgstr "" + +#: tenancy/forms/bulk_edit.py:65 +msgid "Desciption" +msgstr "" + +#: tenancy/forms/bulk_import.py:101 +msgid "Assigned contact" +msgstr "" + +#: tenancy/models/contacts.py:31 +msgid "contact group" +msgstr "" + +#: tenancy/models/contacts.py:32 +msgid "contact groups" +msgstr "" + +#: tenancy/models/contacts.py:47 +msgid "contact role" +msgstr "" + +#: tenancy/models/contacts.py:48 +msgid "contact roles" +msgstr "" + +#: tenancy/models/contacts.py:67 +msgid "title" +msgstr "" + +#: tenancy/models/contacts.py:72 +msgid "phone" +msgstr "" + +#: tenancy/models/contacts.py:77 +msgid "email" +msgstr "" + +#: tenancy/models/contacts.py:86 +msgid "link" +msgstr "" + +#: tenancy/models/contacts.py:102 +msgid "contact" +msgstr "" + +#: tenancy/models/contacts.py:103 +msgid "contacts" +msgstr "" + +#: tenancy/models/contacts.py:149 +msgid "contact assignment" +msgstr "" + +#: tenancy/models/contacts.py:150 +msgid "contact assignments" +msgstr "" + +#: tenancy/models/tenants.py:32 +msgid "tenant group" +msgstr "" + +#: tenancy/models/tenants.py:33 +msgid "tenant groups" +msgstr "" + +#: tenancy/models/tenants.py:70 +msgid "Tenant name must be unique per group." +msgstr "" + +#: tenancy/models/tenants.py:80 +msgid "Tenant slug must be unique per group." +msgstr "" + +#: tenancy/models/tenants.py:88 +msgid "tenant" +msgstr "" + +#: tenancy/models/tenants.py:89 +msgid "tenants" +msgstr "" + +#: tenancy/tables/contacts.py:107 +msgid "Contact Title" +msgstr "" + +#: tenancy/tables/contacts.py:111 +msgid "Contact Phone" +msgstr "" + +#: tenancy/tables/contacts.py:115 +msgid "Contact Email" +msgstr "" + +#: tenancy/tables/contacts.py:119 +msgid "Contact Address" +msgstr "" + +#: tenancy/tables/contacts.py:123 +msgid "Contact Link" +msgstr "" + +#: tenancy/tables/contacts.py:127 +msgid "Contact Description" +msgstr "" + +#: users/filtersets.py:48 users/filtersets.py:151 +msgid "Group (name)" +msgstr "" + +#: users/forms/bulk_edit.py:24 +msgid "First name" +msgstr "" + +#: users/forms/bulk_edit.py:29 +msgid "Last name" +msgstr "" + +#: users/forms/bulk_edit.py:41 +msgid "Staff status" +msgstr "" + +#: users/forms/bulk_edit.py:46 +msgid "Superuser status" +msgstr "" + +#: users/forms/bulk_import.py:43 +msgid "If no key is provided, one will be generated automatically." +msgstr "" + +#: users/forms/filtersets.py:54 users/tables.py:42 +msgid "Is Staff" +msgstr "" + +#: users/forms/filtersets.py:61 users/tables.py:45 +msgid "Is Superuser" +msgstr "" + +#: users/forms/filtersets.py:94 users/tables.py:89 +msgid "Can View" +msgstr "" + +#: users/forms/filtersets.py:101 users/tables.py:92 +msgid "Can Add" +msgstr "" + +#: users/forms/filtersets.py:108 users/tables.py:95 +msgid "Can Change" +msgstr "" + +#: users/forms/filtersets.py:115 users/tables.py:98 +msgid "Can Delete" +msgstr "" + +#: users/forms/model_forms.py:58 +msgid "User Interface" +msgstr "" + +#: users/forms/model_forms.py:115 +msgid "" +"Keys must be at least 40 characters in length. Be sure to record " +"your key prior to submitting this form, as it may no longer be " +"accessible once the token has been created." +msgstr "" + +#: users/forms/model_forms.py:127 +msgid "" +"Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for " +"no restrictions. Example: 10.1.1.0/24,192.168.10.16/32,2001:" +"db8:1::/64" +msgstr "" + +#: users/forms/model_forms.py:176 +msgid "Confirm password" +msgstr "" + +#: users/forms/model_forms.py:179 +msgid "Enter the same password as before, for verification." +msgstr "" + +#: users/forms/model_forms.py:237 +msgid "Passwords do not match! Please check your input and try again." +msgstr "" + +#: users/forms/model_forms.py:303 +msgid "Additional actions" +msgstr "" + +#: users/forms/model_forms.py:306 +msgid "Actions granted in addition to those listed above" +msgstr "" + +#: users/forms/model_forms.py:322 +msgid "Objects" +msgstr "" + +#: users/forms/model_forms.py:334 +msgid "" +"JSON expression of a queryset filter that will return only permitted " +"objects. Leave null to match all objects of this type. A list of multiple " +"objects will result in a logical OR operation." +msgstr "" + +#: users/forms/model_forms.py:372 +msgid "At least one action must be selected." +msgstr "" + +#: users/forms/model_forms.py:389 +#, python-brace-format +msgid "Invalid filter for {model}: {error}" +msgstr "" + +#: users/models.py:54 +msgid "user" +msgstr "" + +#: users/models.py:55 +msgid "users" +msgstr "" + +#: users/models.py:66 +msgid "A user with this username already exists." +msgstr "" + +#: users/models.py:78 +msgid "group" +msgstr "" + +#: users/models.py:79 +msgid "groups" +msgstr "" + +#: users/models.py:104 users/models.py:105 +msgid "user preferences" +msgstr "" + +#: users/models.py:172 +#, python-brace-format +msgid "Key '{path}' is a leaf node; cannot assign new keys" +msgstr "" + +#: users/models.py:184 +#, python-brace-format +msgid "Key '{path}' is a dictionary; cannot assign a non-dictionary value" +msgstr "" + +#: users/models.py:249 +msgid "expires" +msgstr "" + +#: users/models.py:254 +msgid "last used" +msgstr "" + +#: users/models.py:259 +msgid "key" +msgstr "" + +#: users/models.py:265 +msgid "write enabled" +msgstr "" + +#: users/models.py:267 +msgid "Permit create/update/delete operations using this key" +msgstr "" + +#: users/models.py:278 +msgid "allowed IPs" +msgstr "" + +#: users/models.py:280 +msgid "" +"Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for " +"no restrictions. Ex: \"10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64\"" +msgstr "" + +#: users/models.py:288 +msgid "token" +msgstr "" + +#: users/models.py:289 +msgid "tokens" +msgstr "" + +#: users/models.py:370 +msgid "The list of actions granted by this permission" +msgstr "" + +#: users/models.py:375 +msgid "constraints" +msgstr "" + +#: users/models.py:376 +msgid "Queryset filter matching the applicable objects of the selected type(s)" +msgstr "" + +#: users/models.py:383 +msgid "permission" +msgstr "" + +#: users/models.py:384 +msgid "permissions" +msgstr "" + +#: users/tables.py:101 +msgid "Custom Actions" +msgstr "" + +#: utilities/choices.py:16 +#, python-brace-format +msgid "{name} has a key defined but CHOICES is not a list" +msgstr "" + +#: utilities/choices.py:135 +msgid "Dark Red" +msgstr "" + +#: utilities/choices.py:138 +msgid "Rose" +msgstr "" + +#: utilities/choices.py:139 +msgid "Fuchsia" +msgstr "" + +#: utilities/choices.py:141 +msgid "Dark Purple" +msgstr "" + +#: utilities/choices.py:144 +msgid "Light Blue" +msgstr "" + +#: utilities/choices.py:147 +msgid "Aqua" +msgstr "" + +#: utilities/choices.py:148 +msgid "Dark Green" +msgstr "" + +#: utilities/choices.py:150 +msgid "Light Green" +msgstr "" + +#: utilities/choices.py:151 +msgid "Lime" +msgstr "" + +#: utilities/choices.py:153 +msgid "Amber" +msgstr "" + +#: utilities/choices.py:155 +msgid "Dark Orange" +msgstr "" + +#: utilities/choices.py:156 +msgid "Brown" +msgstr "" + +#: utilities/choices.py:157 +msgid "Light Grey" +msgstr "" + +#: utilities/choices.py:158 +msgid "Grey" +msgstr "" + +#: utilities/choices.py:159 +msgid "Dark Grey" +msgstr "" + +#: utilities/choices.py:217 +msgid "Direct" +msgstr "" + +#: utilities/choices.py:218 +msgid "Upload" +msgstr "" + +#: utilities/choices.py:230 utilities/choices.py:244 +msgid "Auto-detect" +msgstr "" + +#: utilities/choices.py:245 +msgid "Comma" +msgstr "" + +#: utilities/choices.py:246 +msgid "Semicolon" +msgstr "" + +#: utilities/choices.py:247 +msgid "Tab" +msgstr "" + +#: utilities/fields.py:162 +#, python-format +msgid "" +"%s(%r) is invalid. to_model parameter to CounterCacheField must be a string " +"in the format 'app.model'" +msgstr "" + +#: utilities/fields.py:172 +#, python-format +msgid "" +"%s(%r) is invalid. to_field parameter to CounterCacheField must be a string " +"in the format 'field'" +msgstr "" + +#: utilities/forms/bulk_import.py:24 +msgid "Enter object data in CSV, JSON or YAML format." +msgstr "" + +#: utilities/forms/bulk_import.py:37 +msgid "CSV delimiter" +msgstr "" + +#: utilities/forms/bulk_import.py:38 +msgid "The character which delimits CSV fields. Applies only to CSV format." +msgstr "" + +#: utilities/forms/bulk_import.py:101 +msgid "Unable to detect data format. Please specify." +msgstr "" + +#: utilities/forms/bulk_import.py:124 +msgid "Invalid CSV delimiter" +msgstr "" + +#: utilities/forms/bulk_import.py:168 +msgid "" +"Invalid YAML data. Data must be in the form of multiple documents, or a " +"single document comprising a list of dictionaries." +msgstr "" + +#: utilities/forms/fields/array.py:17 +#, python-brace-format +msgid "" +"Invalid list ({value}). Must be numeric and ranges must be in ascending " +"order." +msgstr "" + +#: utilities/forms/fields/csv.py:44 +#, python-brace-format +msgid "Invalid value for a multiple choice field: {value}" +msgstr "" + +#: utilities/forms/fields/csv.py:57 utilities/forms/fields/csv.py:74 +#, python-format +msgid "Object not found: %(value)s" +msgstr "" + +#: utilities/forms/fields/csv.py:65 +#, python-brace-format +msgid "" +"\"{value}\" is not a unique value for this field; multiple objects were found" +msgstr "" + +#: utilities/forms/fields/csv.py:97 +msgid "Object type must be specified as \".\"" +msgstr "" + +#: utilities/forms/fields/csv.py:101 +msgid "Invalid object type" +msgstr "" + +#: utilities/forms/fields/expandable.py:25 +msgid "" +"Alphanumeric ranges are supported for bulk creation. Mixed cases and types " +"within a single range are not supported (example: [ge,xe]-0/0/[0-9])." +msgstr "" + +#: utilities/forms/fields/expandable.py:46 +msgid "" +"Specify a numeric range to create multiple IPs.
    Example: 192.0.2." +"[1,5,100-254]/24" +msgstr "" + +#: utilities/forms/fields/fields.py:31 +#, python-brace-format +msgid "" +" Markdown syntax is supported" +msgstr "" + +#: utilities/forms/fields/fields.py:48 +msgid "URL-friendly unique shorthand" +msgstr "" + +#: utilities/forms/fields/fields.py:99 +msgid "Enter context data in JSON format." +msgstr "" + +#: utilities/forms/fields/fields.py:117 +msgid "MAC address must be in EUI-48 format" +msgstr "" + +#: utilities/forms/forms.py:53 +msgid "Use regular expressions" +msgstr "" + +#: utilities/forms/forms.py:87 +#, python-brace-format +msgid "Unrecognized header: {name}" +msgstr "" + +#: utilities/forms/forms.py:113 +msgid "Available Columns" +msgstr "" + +#: utilities/forms/forms.py:121 +msgid "Selected Columns" +msgstr "" + +#: utilities/forms/mixins.py:101 +msgid "" +"This object has been modified since the form was rendered. Please consult " +"the object's change log for details." +msgstr "" + +#: utilities/templates/builtins/customfield_value.html:30 +msgid "Not defined" +msgstr "" + +#: utilities/templates/buttons/bookmark.html:9 +msgid "Unbookmark" +msgstr "" + +#: utilities/templates/buttons/bookmark.html:13 +msgid "Bookmark" +msgstr "" + +#: utilities/templates/buttons/clone.html:4 +msgid "Clone" +msgstr "" + +#: utilities/templates/buttons/export.html:4 +msgid "Export" +msgstr "" + +#: utilities/templates/buttons/export.html:7 +msgid "Current View" +msgstr "" + +#: utilities/templates/buttons/export.html:8 +msgid "All Data" +msgstr "" + +#: utilities/templates/buttons/export.html:28 +msgid "Add export template" +msgstr "" + +#: utilities/templates/buttons/import.html:4 +msgid "Import" +msgstr "" + +#: utilities/templates/form_helpers/render_field.html:36 +msgid "Copy to clipboard" +msgstr "" + +#: utilities/templates/form_helpers/render_field.html:52 +msgid "This field is required" +msgstr "" + +#: utilities/templates/form_helpers/render_field.html:65 +msgid "Set Null" +msgstr "" + +#: utilities/templates/helpers/applied_filters.html:11 +msgid "Clear all" +msgstr "" + +#: utilities/templates/helpers/table_config_form.html:8 +msgid "Table Configuration" +msgstr "" + +#: utilities/templates/helpers/table_config_form.html:31 +msgid "Move Up" +msgstr "" + +#: utilities/templates/helpers/table_config_form.html:34 +msgid "Move Down" +msgstr "" + +#: utilities/templates/widgets/apiselect.html:7 +msgid "Open selector" +msgstr "" + +#: utilities/templates/widgets/clearable_file_input.html:12 +msgid "None assigned" +msgstr "" + +#: utilities/templates/widgets/markdown_input.html:6 +msgid "Write" +msgstr "" + +#: utilities/templates/widgets/markdown_input.html:20 +msgid "Testing" +msgstr "" + +#: virtualization/filtersets.py:77 +msgid "Parent group (ID)" +msgstr "" + +#: virtualization/filtersets.py:83 +msgid "Parent group (slug)" +msgstr "" + +#: virtualization/filtersets.py:87 virtualization/filtersets.py:137 +msgid "Cluster type (ID)" +msgstr "" + +#: virtualization/filtersets.py:126 +msgid "Cluster group (ID)" +msgstr "" + +#: virtualization/filtersets.py:147 virtualization/filtersets.py:262 +msgid "Cluster (ID)" +msgstr "" + +#: virtualization/forms/bulk_edit.py:163 +#: virtualization/models/virtualmachines.py:112 +msgid "vCPUs" +msgstr "" + +#: virtualization/forms/bulk_edit.py:167 +msgid "Memory (MB)" +msgstr "" + +#: virtualization/forms/bulk_edit.py:171 +msgid "Disk (GB)" +msgstr "" + +#: virtualization/forms/bulk_import.py:43 +msgid "Type of cluster" +msgstr "" + +#: virtualization/forms/bulk_import.py:50 +msgid "Assigned cluster group" +msgstr "" + +#: virtualization/forms/bulk_import.py:95 +msgid "Assigned cluster" +msgstr "" + +#: virtualization/forms/bulk_import.py:102 +msgid "Assigned device within cluster" +msgstr "" + +#: virtualization/forms/model_forms.py:155 +#, python-brace-format +msgid "" +"{device} belongs to a different site ({device_site}) than the cluster " +"({cluster_site})" +msgstr "" + +#: virtualization/forms/model_forms.py:194 +msgid "Optionally pin this VM to a specific host device within the cluster" +msgstr "" + +#: virtualization/forms/model_forms.py:222 +msgid "Site/Cluster" +msgstr "" + +#: virtualization/models/clusters.py:25 +msgid "cluster type" +msgstr "" + +#: virtualization/models/clusters.py:26 +msgid "cluster types" +msgstr "" + +#: virtualization/models/clusters.py:45 +msgid "cluster group" +msgstr "" + +#: virtualization/models/clusters.py:46 +msgid "cluster groups" +msgstr "" + +#: virtualization/models/clusters.py:121 +msgid "cluster" +msgstr "" + +#: virtualization/models/clusters.py:122 +msgid "clusters" +msgstr "" + +#: virtualization/models/clusters.py:141 +#, python-brace-format +msgid "" +"{count} devices are assigned as hosts for this cluster but are not in site " +"{site}" +msgstr "" + +#: virtualization/models/virtualmachines.py:120 +msgid "memory (MB)" +msgstr "" + +#: virtualization/models/virtualmachines.py:125 +msgid "disk (GB)" +msgstr "" + +#: virtualization/models/virtualmachines.py:154 +msgid "Virtual machine name must be unique per cluster." +msgstr "" + +#: virtualization/models/virtualmachines.py:157 +msgid "virtual machine" +msgstr "" + +#: virtualization/models/virtualmachines.py:158 +msgid "virtual machines" +msgstr "" + +#: virtualization/models/virtualmachines.py:172 +msgid "A virtual machine must be assigned to a site and/or cluster." +msgstr "" + +#: virtualization/models/virtualmachines.py:179 +#, python-brace-format +msgid "The selected cluster ({cluster}) is not assigned to this site ({site})." +msgstr "" + +#: virtualization/models/virtualmachines.py:186 +msgid "Must specify a cluster when assigning a host device." +msgstr "" + +#: virtualization/models/virtualmachines.py:191 +#, python-brace-format +msgid "" +"The selected device ({device}) is not assigned to this cluster ({cluster})." +msgstr "" + +#: virtualization/models/virtualmachines.py:204 +#, python-brace-format +msgid "Must be an IPv{family} address. ({ip} is an IPv{version} address.)" +msgstr "" + +#: virtualization/models/virtualmachines.py:213 +#, python-brace-format +msgid "The specified IP address ({ip}) is not assigned to this VM." +msgstr "" + +#: virtualization/models/virtualmachines.py:331 +#, python-brace-format +msgid "" +"The selected parent interface ({parent}) belongs to a different virtual " +"machine ({virtual_machine})." +msgstr "" + +#: virtualization/models/virtualmachines.py:346 +#, python-brace-format +msgid "" +"The selected bridge interface ({bridge}) belongs to a different virtual " +"machine ({virtual_machine})." +msgstr "" + +#: virtualization/models/virtualmachines.py:357 +#, python-brace-format +msgid "" +"The untagged VLAN ({untagged_vlan}) must belong to the same site as the " +"interface's parent virtual machine, or it must be global." +msgstr "" + +#: wireless/choices.py:11 +msgid "Access point" +msgstr "" + +#: wireless/choices.py:12 +msgid "Station" +msgstr "" + +#: wireless/choices.py:467 +msgid "Open" +msgstr "" + +#: wireless/choices.py:469 +msgid "WPA Personal (PSK)" +msgstr "" + +#: wireless/choices.py:470 +msgid "WPA Enterprise" +msgstr "" + +#: wireless/forms/bulk_edit.py:72 wireless/forms/bulk_edit.py:119 +#: wireless/forms/bulk_import.py:68 wireless/forms/bulk_import.py:71 +#: wireless/forms/bulk_import.py:110 wireless/forms/bulk_import.py:113 +#: wireless/forms/filtersets.py:58 wireless/forms/filtersets.py:92 +msgid "Authentication cipher" +msgstr "" + +#: wireless/forms/bulk_edit.py:78 wireless/forms/bulk_edit.py:125 +#: wireless/forms/filtersets.py:63 wireless/forms/filtersets.py:97 +msgid "Pre-shared key" +msgstr "" + +#: wireless/forms/bulk_import.py:52 +msgid "Bridged VLAN" +msgstr "" + +#: wireless/forms/bulk_import.py:89 wireless/tables/wirelesslink.py:27 +msgid "Interface A" +msgstr "" + +#: wireless/forms/bulk_import.py:93 wireless/tables/wirelesslink.py:36 +msgid "Interface B" +msgstr "" + +#: wireless/forms/model_forms.py:158 +msgid "Side B" +msgstr "" + +#: wireless/models.py:30 +msgid "authentication cipher" +msgstr "" + +#: wireless/models.py:38 +msgid "pre-shared key" +msgstr "" + +#: wireless/models.py:68 +msgid "wireless LAN group" +msgstr "" + +#: wireless/models.py:69 +msgid "wireless LAN groups" +msgstr "" + +#: wireless/models.py:115 +msgid "wireless LAN" +msgstr "" + +#: wireless/models.py:143 +msgid "interface A" +msgstr "" + +#: wireless/models.py:150 +msgid "interface B" +msgstr "" + +#: wireless/models.py:198 +msgid "wireless link" +msgstr "" + +#: wireless/models.py:199 +msgid "wireless links" +msgstr "" + +#: wireless/models.py:216 wireless/models.py:222 +#, python-brace-format +msgid "{type} is not a wireless interface." +msgstr "" diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1f4bf4ea0..75ab877cf 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,11 +1,12 @@ from django.conf import settings +from django.contrib.auth import authenticate from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes from rest_framework import serializers -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField from netbox.api.serializers import ValidatedModelSerializer @@ -107,9 +108,42 @@ class TokenSerializer(ValidatedModelSerializer): return super().validate(data) -class TokenProvisionSerializer(serializers.Serializer): - username = serializers.CharField() - password = serializers.CharField() +class TokenProvisionSerializer(TokenSerializer): + user = NestedUserSerializer( + read_only=True + ) + username = serializers.CharField( + write_only=True + ) + password = serializers.CharField( + write_only=True + ) + last_used = serializers.DateTimeField( + read_only=True + ) + key = serializers.CharField( + read_only=True + ) + + class Meta: + model = Token + fields = ( + 'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description', + 'allowed_ips', 'username', 'password', + ) + + def validate(self, data): + # Validate the username and password + username = data.pop('username') + password = data.pop('password') + user = authenticate(request=self.context.get('request'), username=username, password=password) + if user is None: + raise AuthenticationFailed("Invalid username/password") + + # Inject the user into the validated data + data['user'] = user + + return data class ObjectPermissionSerializer(ValidatedModelSerializer): diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 9cf5b1ac5..62a32c71b 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,3 +1,4 @@ +import logging from django.contrib.auth import authenticate from django.contrib.auth import get_user_model from django.contrib.auth.models import Group @@ -63,34 +64,21 @@ class TokenProvisionView(APIView): @extend_schema( request=serializers.TokenProvisionSerializer, responses={ - 201: serializers.TokenSerializer, + 201: serializers.TokenProvisionSerializer, 401: OpenApiTypes.OBJECT, } ) def post(self, request): - serializer = serializers.TokenProvisionSerializer(data=request.data) - serializer.is_valid() + serializer = serializers.TokenProvisionSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(serializer.data, status=HTTP_201_CREATED) - # Authenticate the user account based on the provided credentials - username = serializer.data.get('username') - password = serializer.data.get('password') - if not username or not password: - raise AuthenticationFailed("Username and password must be provided to provision a token.") - user = authenticate(request=request, username=username, password=password) - if user is None: - raise AuthenticationFailed("Invalid username/password") - - # Create a new Token for the User - token = Token(user=user) - token.save() - data = serializers.TokenSerializer(token, context={'request': request}).data - # Manually append the token key, which is normally write-only - data['key'] = token.key - - return Response(data, status=HTTP_201_CREATED) - - def get_serializer_class(self): - return serializers.TokenSerializer + def perform_create(self, serializer): + model = serializer.Meta.model + logger = logging.getLogger(f'netbox.api.views.TokenProvisionView') + logger.info(f"Creating new {model._meta.verbose_name}") + serializer.save() # diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index 5fe84ad5f..b0a43ef22 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm): help_text=_( 'Keys must be at least 40 characters in length. Be sure to record your key prior to ' 'submitting this form, as it may no longer be accessible once the token has been created.' + ), + widget=forms.TextInput( + attrs={'data-clipboard': 'true'} ) ) allowed_ips = SimpleArrayField( @@ -383,5 +386,5 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm): model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() except FieldError as e: raise forms.ValidationError({ - 'constraints': _('Invalid filter for {model}: {e}').format(model=model, e=e) + 'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e) }) diff --git a/netbox/users/models.py b/netbox/users/models.py index 80fd0dd09..d77d4932c 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -3,7 +3,6 @@ import os from django.conf import settings from django.contrib.auth.models import Group, GroupManager, User, UserManager -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator @@ -15,6 +14,7 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from netaddr import IPNetwork +from core.models import ContentType from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet @@ -99,6 +99,8 @@ class UserConfig(models.Model): default=dict ) + _netbox_private = True + class Meta: ordering = ['user'] verbose_name = _('user preferences') @@ -169,7 +171,7 @@ class UserConfig(models.Model): elif key in d: err_path = '.'.join(path.split('.')[:i + 1]) raise TypeError( - _("Key '{err_path}' is a leaf node; cannot assign new keys").format(err_path=err_path) + _("Key '{path}' is a leaf node; cannot assign new keys").format(path=err_path) ) else: d = d.setdefault(key, {}) @@ -351,7 +353,7 @@ class ObjectPermission(models.Model): default=True ) object_types = models.ManyToManyField( - to=ContentType, + to='contenttypes.ContentType', limit_choices_to=OBJECTPERMISSION_OBJECT_TYPES, related_name='object_permissions' ) diff --git a/netbox/users/tables.py b/netbox/users/tables.py index 3b418715a..afb270568 100644 --- a/netbox/users/tables.py +++ b/netbox/users/tables.py @@ -52,7 +52,7 @@ class UserTable(NetBoxTable): model = NetBoxUser fields = ( 'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff', - 'is_superuser', + 'is_superuser', 'last_login', ) default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active') diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 859dd0b83..001142410 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -141,17 +141,25 @@ class TokenTest( """ Test the provisioning of a new REST API token given a valid username and password. """ - data = { + user_credentials = { 'username': 'user1', 'password': 'abc123', } - user = User.objects.create_user(**data) + user = User.objects.create_user(**user_credentials) + + data = { + **user_credentials, + 'description': 'My API token', + 'expires': '2099-12-31T23:59:59Z', + } url = reverse('users-api:token_provision') response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) self.assertIn('key', response.data) self.assertEqual(len(response.data['key']), 40) + self.assertEqual(response.data['description'], data['description']) + self.assertEqual(response.data['expires'], data['expires']) token = Token.objects.get(user=user) self.assertEqual(token.key, response.data['key']) diff --git a/netbox/users/views.py b/netbox/users/views.py index 7ff9a8a4d..2e7a47c12 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -68,7 +68,7 @@ class UserView(generic.ObjectView): template_name = 'users/user.html' def get_extra_context(self, request, instance): - changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user)[:20] + changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=instance)[:20] changelog_table = ObjectChangeTable(changelog) return { diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b6f97e309..77bfc03ca 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,6 +1,8 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ +from .constants import CSV_DELIMITERS + class ChoiceSetMeta(type): """ @@ -230,3 +232,17 @@ class ImportFormatChoices(ChoiceSet): (JSON, 'JSON'), (YAML, 'YAML'), ] + + +class CSVDelimiterChoices(ChoiceSet): + AUTO = 'auto' + COMMA = CSV_DELIMITERS['comma'] + SEMICOLON = CSV_DELIMITERS['semicolon'] + TAB = CSV_DELIMITERS['tab'] + + CHOICES = [ + (AUTO, _('Auto-detect')), + (COMMA, _('Comma')), + (SEMICOLON, _('Semicolon')), + (TAB, _('Tab')), + ] diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 5c551a810..345894065 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -58,3 +58,14 @@ HTTP_REQUEST_META_SAFE_COPY = [ 'SERVER_NAME', 'SERVER_PORT', ] + + +# +# CSV-style format delimiters +# + +CSV_DELIMITERS = { + 'comma': ',', + 'semicolon': ';', + 'tab': '\t', +} diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py index 6c1418dff..0ee2606db 100644 --- a/netbox/utilities/counters.py +++ b/netbox/utilities/counters.py @@ -1,5 +1,5 @@ from django.apps import apps -from django.db.models import F +from django.db.models import F, Count, OuterRef, Subquery from django.db.models.signals import post_delete, post_save from netbox.registry import registry @@ -23,6 +23,24 @@ def update_counter(model, pk, counter_name, value): ) +def update_counts(model, field_name, related_query): + """ + Perform a bulk update for the given model and counter field. For example, + + update_counts(Device, '_interface_count', 'interfaces') + + will effectively set + + Device.objects.update(_interface_count=Count('interfaces')) + """ + subquery = Subquery( + model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count') + ) + return model.objects.update(**{ + field_name: subquery + }) + + # # Signal handlers # @@ -34,16 +52,17 @@ def post_save_receiver(sender, instance, created, **kwargs): for field_name, counter_name in get_counters_for_model(sender): parent_model = sender._meta.get_field(field_name).related_model new_pk = getattr(instance, field_name, None) - old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None + has_old_field = field_name in instance.tracker + old_pk = instance.tracker.get(field_name) if has_old_field else None # Update the counters on the old and/or new parents as needed if old_pk is not None: update_counter(parent_model, old_pk, counter_name, -1) - if new_pk is not None and (old_pk or created): + if new_pk is not None and (has_old_field or created): update_counter(parent_model, new_pk, counter_name, 1) -def post_delete_receiver(sender, instance, **kwargs): +def post_delete_receiver(sender, instance, origin, **kwargs): """ Update counter fields on related objects when a TrackingModelMixin subclass is deleted. """ @@ -53,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs): # Decrement the parent's counter by one if parent_pk is not None: - update_counter(parent_model, parent_pk, counter_name, -1) + # MPTT sends two delete signals for child elements so guard against multiple decrements + if not origin or origin == instance: + update_counter(parent_model, parent_pk, counter_name, -1) # diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index 1d3bdbafd..9af12ac2e 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -1,16 +1,26 @@ from django.contrib import messages +from django.db.models import ProtectedError, RestrictedError from django.utils.html import escape from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ def handle_protectederror(obj_list, request, e): """ - Generate a user-friendly error message in response to a ProtectedError exception. + Generate a user-friendly error message in response to a ProtectedError or RestrictedError exception. """ - protected_objects = list(e.protected_objects) - protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50' - err_message = f"Unable to delete {', '.join(str(obj) for obj in obj_list)}. " \ - f"{protected_count} dependent objects were found: " + if type(e) is ProtectedError: + protected_objects = list(e.protected_objects) + elif type(e) is RestrictedError: + protected_objects = list(e.restricted_objects) + else: + raise e + + # Formulate the error message + err_message = _("Unable to delete {objects}. {count} dependent objects were found: ").format( + objects=', '.join(str(obj) for obj in obj_list), + count=len(protected_objects) if len(protected_objects) <= 50 else _('More than 50') + ) # Append dependent objects to error message dependent_objects = [] diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index 6bdfd5662..57362d3dd 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -7,10 +7,10 @@ from django import forms from django.utils.translation import gettext as _ from core.forms.mixins import SyncedDataMixin -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices +from utilities.constants import CSV_DELIMITERS from utilities.forms.utils import parse_csv from .mixins import BootstrapMixin -from ..choices import ImportMethodChoices class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): @@ -24,13 +24,20 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): help_text=_("Enter object data in CSV, JSON or YAML format.") ) upload_file = forms.FileField( - label="Data file", + label=_("Data file"), required=False ) format = forms.ChoiceField( choices=ImportFormatChoices, initial=ImportFormatChoices.AUTO ) + csv_delimiter = forms.ChoiceField( + choices=CSVDelimiterChoices, + initial=CSVDelimiterChoices.AUTO, + label=_("CSV delimiter"), + help_text=_("The character which delimits CSV fields. Applies only to CSV format."), + required=False + ) data_field = 'data' @@ -54,13 +61,18 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): # Determine the data format if self.cleaned_data['format'] == ImportFormatChoices.AUTO: - format = self._detect_format(data) + if self.cleaned_data['csv_delimiter'] != CSVDelimiterChoices.AUTO: + # Specifying the CSV delimiter implies CSV format + format = ImportFormatChoices.CSV + else: + format = self._detect_format(data) else: format = self.cleaned_data['format'] # Process data according to the selected format if format == ImportFormatChoices.CSV: - self.cleaned_data['data'] = self._clean_csv(data) + delimiter = self.cleaned_data.get('csv_delimiter', CSVDelimiterChoices.AUTO) + self.cleaned_data['data'] = self._clean_csv(data, delimiter=delimiter) elif format == ImportFormatChoices.JSON: self.cleaned_data['data'] = self._clean_json(data) elif format == ImportFormatChoices.YAML: @@ -78,7 +90,10 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): return ImportFormatChoices.JSON if data.startswith('---') or data.startswith('- '): return ImportFormatChoices.YAML - if ',' in data.split('\n', 1)[0]: + # Look for any of the CSV delimiters in the first line (ignoring the default 'auto' choice) + first_line = data.split('\n', 1)[0] + csv_delimiters = CSV_DELIMITERS.values() + if any(x in first_line for x in csv_delimiters): return ImportFormatChoices.CSV except IndexError: pass @@ -86,15 +101,35 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): 'format': _('Unable to detect data format. Please specify.') }) - def _clean_csv(self, data): + def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO): """ Clean CSV-formatted data. The first row will be treated as column headers. """ + # Determine the CSV dialect + if delimiter == CSVDelimiterChoices.AUTO: + # This uses a rough heuristic to detect the CSV dialect based on the presence of supported delimiting + # characters. If the data is malformed, we'll fall back to the default Excel dialect. + delimiters = ''.join(CSV_DELIMITERS.values()) + try: + dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters) + except csv.Error: + dialect = csv.excel + elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON): + dialect = csv.excel + dialect.delimiter = delimiter + elif delimiter == CSVDelimiterChoices.TAB: + dialect = csv.excel_tab + else: + raise forms.ValidationError({ + 'csv_delimiter': _('Invalid CSV delimiter'), + }) + stream = StringIO(data.strip()) - reader = csv.reader(stream) + reader = csv.reader(stream, dialect=dialect) headers, records = parse_csv(reader) # Set CSV headers for reference by the model form + headers.pop('id', None) self._csv_headers = headers return records diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index 9f84e100f..54c9e41cb 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form): """ An extendable form to be used for renaming objects in bulk. """ - find = forms.CharField() + find = forms.CharField( + strip=False + ) replace = forms.CharField( + strip=False, required=False ) use_regex = forms.BooleanField( @@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm): """ ModelForm used for the import of objects in CSV format. """ - def __init__(self, *args, headers=None, fields=None, **kwargs): - headers = headers or {} - fields = fields or [] + def __init__(self, *args, headers=None, **kwargs): + self.headers = headers or {} super().__init__(*args, **kwargs) # Modify the model form to accommodate any customized to_field_name properties - for field, to_field in headers.items(): + for field, to_field in self.headers.items(): if to_field is not None: self.fields[field].to_field_name = to_field - # Omit any fields not specified (e.g. because the form is being used to - # updated rather than create objects) - if fields: - for field in list(self.fields.keys()): - if field not in fields: - del self.fields[field] + def clean(self): + # Flag any invalid CSV headers + for header in self.headers: + if header not in self.fields: + raise forms.ValidationError( + _("Unrecognized header: {name}").format(name=header) + ) + + return super().clean() class FilterForm(BootstrapMixin, forms.Form): diff --git a/netbox/utilities/management/commands/calculate_cached_counts.py b/netbox/utilities/management/commands/calculate_cached_counts.py index 62354797c..f7810604f 100644 --- a/netbox/utilities/management/commands/calculate_cached_counts.py +++ b/netbox/utilities/management/commands/calculate_cached_counts.py @@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand from django.db.models import Count, OuterRef, Subquery from netbox.registry import registry +from utilities.counters import update_counts class Command(BaseCommand): @@ -26,27 +27,9 @@ class Command(BaseCommand): return models - def update_counts(self, model, field_name, related_query): - """ - Perform a bulk update for the given model and counter field. For example, - - update_counts(Device, '_interface_count', 'interfaces') - - will effectively set - - Device.objects.update(_interface_count=Count('interfaces')) - """ - self.stdout.write(f'Updating {model.__name__} {field_name}...') - subquery = Subquery( - model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count') - ) - return model.objects.update(**{ - field_name: subquery - }) - def handle(self, *model_names, **options): for model, mappings in self.collect_models().items(): for field_name, related_query in mappings.items(): - self.update_counts(model, field_name, related_query) + update_counts(model, field_name, related_query) self.stdout.write(self.style.SUCCESS('Finished.')) diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 3b8e1edde..0f8ee9cae 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()): ) for header in HTTP_HEADERS: if header in request.META: - client_ip = request.META[header].split(',')[0] + client_ip = request.META[header].split(',')[0].partition(':')[0] try: return IPAddress(client_ip) except ValueError: diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index bf6aa15a9..654eb02be 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -1,8 +1,27 @@ +from netbox.registry import registry + __all__ = ( + 'get_table_ordering', 'linkify_phone', + 'register_table_column' ) +def get_table_ordering(request, table): + """ + Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering + the table itself. + """ + # Check for an explicit ordering + if 'sort' in request.GET: + return request.GET['sort'] or None + + # Check for a configured preference + if request.user.is_authenticated: + if preference := request.user.config.get(f'tables.{table.__name__}.ordering'): + return preference + + def linkify_phone(value): """ Render a telephone number as a hyperlink. @@ -10,3 +29,19 @@ def linkify_phone(value): if value is None: return None return f"tel:{value}" + + +def register_table_column(column, name, *tables): + """ + Register a custom column for use on one or more tables. + + Args: + column: The column instance to register + name: The name of the table column + tables: One or more table classes + """ + for table in tables: + reg = registry['tables'][table] + if name in reg: + raise ValueError(f"A column named {name} is already defined for table {table.__name__}") + reg[name] = column diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index 379dcc021..e5a564a3d 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -29,6 +29,14 @@ {{ label }}
    + {# Include a copy-to-clipboard button #} + {% elif 'data-clipboard' in field.field.widget.attrs %} +
    + {{ field }} + +
    {# Default field rendering #} {% else %} {{ field }} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 35aec1000..68541ae5a 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,6 +1,7 @@ from django import template from django.http import QueryDict +from extras.choices import CustomFieldTypeChoices from utilities.utils import dict_to_querydict __all__ = ( @@ -38,6 +39,11 @@ def customfield_value(customfield, value): customfield: A CustomField instance value: The custom field value applied to an object """ + if value: + if customfield.type == CustomFieldTypeChoices.TYPE_SELECT: + value = customfield.get_choice_label(value) + elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + value = [customfield.get_choice_label(v) for v in value] return { 'customfield': customfield, 'value': value, diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index 4a229e952..7534d7034 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -26,11 +26,14 @@ def nav(context: Context) -> Dict: for group in menu.groups: items = [] for item in group.items: - if user.has_perms(item.permissions): - buttons = [ - button for button in item.buttons if user.has_perms(button.permissions) - ] - items.append((item, buttons)) + if not user.has_perms(item.permissions): + continue + if item.staff_only and not user.is_staff: + continue + buttons = [ + button for button in item.buttons if user.has_perms(button.permissions) + ] + items.append((item, buttons)) if items: groups.append((group, items)) if groups: diff --git a/netbox/extras/templatetags/plugins.py b/netbox/utilities/templatetags/plugins.py similarity index 98% rename from netbox/extras/templatetags/plugins.py rename to netbox/utilities/templatetags/plugins.py index 560d15e01..c429bed5f 100644 --- a/netbox/extras/templatetags/plugins.py +++ b/netbox/utilities/templatetags/plugins.py @@ -2,7 +2,7 @@ from django import template as template_ from django.conf import settings from django.utils.safestring import mark_safe -from extras.plugins import PluginTemplateExtension +from netbox.plugins import PluginTemplateExtension from netbox.registry import registry register = template_.Library() diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 3c2dc3c45..0a84c5d1b 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -11,7 +11,7 @@ from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange from netbox.models.features import ChangeLoggingMixin from users.models import ObjectPermission -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from .base import ModelTestCase from .utils import disable_warnings, post_data @@ -580,7 +580,8 @@ class ViewTestCases: def test_bulk_import_objects_without_permission(self): data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Test GET without permission @@ -597,7 +598,8 @@ class ViewTestCases: initial_count = self._get_queryset().count() data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission @@ -626,6 +628,7 @@ class ViewTestCases: data = { 'format': ImportFormatChoices.CSV, 'data': csv_data, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission @@ -658,7 +661,8 @@ class ViewTestCases: initial_count = self._get_queryset().count() data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign constrained permission diff --git a/netbox/utilities/tests/test_counters.py b/netbox/utilities/tests/test_counters.py index 0c61c0890..014c758e9 100644 --- a/netbox/utilities/tests/test_counters.py +++ b/netbox/utilities/tests/test_counters.py @@ -1,7 +1,11 @@ -from django.test import TestCase +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings +from django.urls import reverse from dcim.models import * -from utilities.testing.utils import create_test_device +from users.models import ObjectPermission +from utilities.testing.base import TestCase +from utilities.testing.utils import create_test_device, create_test_user class CountersTest(TestCase): @@ -10,7 +14,6 @@ class CountersTest(TestCase): """ @classmethod def setUpTestData(cls): - # Create devices device1 = create_test_device('Device 1') device2 = create_test_device('Device 2') @@ -36,10 +39,18 @@ class CountersTest(TestCase): self.assertEqual(device1.interface_count, 3) self.assertEqual(device2.interface_count, 3) + # test saving an existing object - counter should not change interface1.save() device1.refresh_from_db() self.assertEqual(device1.interface_count, 3) + # test save where tracked object FK back pointer is None + vc = VirtualChassis.objects.create(name='Virtual Chassis 1') + device1.virtual_chassis = vc + device1.save() + vc.refresh_from_db() + self.assertEqual(vc.member_count, 1) + def test_interface_count_deletion(self): """ When a tracked object (Interface) is deleted the tracking counter should be updated. @@ -71,3 +82,25 @@ class CountersTest(TestCase): device2.refresh_from_db() self.assertEqual(device1.interface_count, 1) self.assertEqual(device2.interface_count, 3) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_mptt_child_delete(self): + device1, device2 = Device.objects.all() + inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1') + inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1) + device1.refresh_from_db() + self.assertEqual(device1.inventory_item_count, 2) + + # Setup bulk_delete for the inventory items + self.add_permissions('dcim.delete_inventoryitem') + pk_list = device1.inventoryitems.values_list('pk', flat=True) + data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, # Form button + } + + # Try POST with model-level permission + self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data) + device1.refresh_from_db() + self.assertEqual(device1.inventory_item_count, 0) diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 79ba3f4d8..d014d4bbd 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -3,6 +3,7 @@ from django.test import TestCase from utilities.choices import ImportFormatChoices from utilities.forms.bulk_import import BulkImportForm +from utilities.forms.forms import BulkRenameForm from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern @@ -331,3 +332,49 @@ class ImportFormTest(TestCase): form._detect_format('') with self.assertRaises(forms.ValidationError): form._detect_format('?') + + def test_csv_delimiters(self): + form = BulkImportForm() + + data = ( + "a,b,c\n" + "1,2,3\n" + "4,5,6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter=','), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ]) + + data = ( + "a;b;c\n" + "1;2;3\n" + "4;5;6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter=';'), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ]) + + data = ( + "a\tb\tc\n" + "1\t2\t3\n" + "4\t5\t6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter='\t'), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ]) + + +class BulkRenameFormTest(TestCase): + def test_no_strip_whitespace(self): + # Tests to make sure Bulk Rename Form isn't stripping whitespaces + # See: https://github.com/netbox-community/netbox/issues/13791 + form = BulkRenameForm(data={ + "find": " hello ", + "replace": " world " + }) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data["find"], " hello ") + self.assertEqual(form.cleaned_data["replace"], " world ") diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 9524e242c..d7232d41b 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -8,7 +8,7 @@ from itertools import count, groupby import bleach from django.contrib.contenttypes.models import ContentType from django.core import serializers -from django.db.models import Count, OuterRef, Subquery +from django.db.models import Count, ManyToOneRel, OuterRef, Subquery from django.db.models.functions import Coalesce from django.http import QueryDict from django.utils import timezone @@ -19,9 +19,9 @@ from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices, WeightUnitChoices -from extras.plugins import PluginConfig from extras.utils import is_taggable from netbox.config import get_config +from netbox.plugins import PluginConfig from urllib.parse import urlencode from utilities.constants import HTTP_REQUEST_META_SAFE_COPY @@ -567,3 +567,20 @@ def local_now(): Return the current date & time in the system timezone. """ return localtime(timezone.now()) + + +def get_related_models(model, ordered=True): + """ + Return a list of all models which have a ForeignKey to the given model and the name of the field. For example, + `get_related_models(Tenant)` will return all models which have a ForeignKey relationship to Tenant. + """ + related_models = [ + (field.related_model, field.remote_field.name) + for field in model._meta.related_objects + if type(field) is ManyToOneRel + ] + + if ordered: + return sorted(related_models, key=lambda x: x[0]._meta.verbose_name) + + return related_models diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 8c3f57c1d..afb7e39a1 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from netbox.api.serializers import WritableNestedSerializer -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import * __all__ = [ 'NestedClusterGroupSerializer', 'NestedClusterSerializer', 'NestedClusterTypeSerializer', + 'NestedVirtualDiskSerializer', 'NestedVMInterfaceSerializer', 'NestedVirtualMachineSerializer', ] @@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer): class Meta: model = VMInterface fields = ['id', 'url', 'display', 'virtual_machine', 'name'] + + +class NestedVirtualDiskSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail') + virtual_machine = NestedVirtualMachineSerializer(read_only=True) + + class Meta: + model = VirtualDisk + fields = ['id', 'url', 'display', 'virtual_machine', 'name', 'size'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index c9fa559aa..95b2152a5 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -14,7 +14,7 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface from .nested_serializers import * @@ -84,6 +84,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): # Counter fields interface_count = serializers.IntegerField(read_only=True) + virtual_disk_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualMachine @@ -91,7 +92,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', - 'interface_count', + 'interface_count', 'virtual_disk_count', ] validators = [] @@ -104,7 +105,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', - 'interface_count', + 'interface_count', 'virtual_disk_count', ] @extend_schema_field(serializers.JSONField(allow_null=True)) @@ -159,3 +160,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer): }) return super().validate(data) + + +# +# Virtual Disk +# + +class VirtualDiskSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail') + virtual_machine = NestedVirtualMachineSerializer() + + class Meta: + model = VirtualDisk + fields = [ + 'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created', + 'last_updated', + ] diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 2ceeb8ce6..ce71605a1 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -13,6 +13,7 @@ router.register('clusters', views.ClusterViewSet) # VirtualMachines router.register('virtual-machines', views.VirtualMachineViewSet) router.register('interfaces', views.VMInterfaceViewSet) +router.register('virtual-disks', views.VirtualDiskViewSet) app_name = 'virtualization-api' urlpatterns = router.urls diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 5b9cf4117..3ba2bb97f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,11 +1,12 @@ from rest_framework.routers import APIRootView from dcim.models import Device -from extras.api.mixins import ConfigContextQuerySetMixin +from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin from netbox.api.viewsets import NetBoxModelViewSet +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from virtualization import filtersets -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import * from . import serializers @@ -52,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet): # Virtual machines # -class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): +class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', + 'tags', 'virtualdisks', ) filterset_class = filtersets.VirtualMachineFilterSet @@ -87,3 +89,16 @@ class VMInterfaceViewSet(NetBoxModelViewSet): serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet brief_prefetch_fields = ['virtual_machine'] + + def get_bulk_destroy_queryset(self): + # Ensure child interfaces are deleted prior to their parents + return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name')) + + +class VirtualDiskViewSet(NetBoxModelViewSet): + queryset = VirtualDisk.objects.prefetch_related( + 'virtual_machine', 'tags', + ) + serializer_class = serializers.VirtualDiskSerializer + filterset_class = filtersets.VirtualDiskFilterSet + brief_prefetch_fields = ['virtual_machine'] diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 8db943ea1..f0af9a163 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -5,7 +5,7 @@ class VirtualizationConfig(AppConfig): name = 'virtualization' def ready(self): - from . import search + from . import search, signals from .models import VirtualMachine from utilities.counters import connect_counters diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 571dbe64b..351166260 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -6,16 +6,18 @@ from dcim.filtersets import CommonInterfaceFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate +from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from .models import * __all__ = ( 'ClusterFilterSet', 'ClusterGroupFilterSet', 'ClusterTypeFilterSet', + 'VirtualDiskFilterSet', 'VirtualMachineFilterSet', 'VMInterfaceFilterSet', ) @@ -114,7 +116,8 @@ class VirtualMachineFilterSet( NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, - LocalConfigContextFilterSet + LocalConfigContextFilterSet, + PrimaryIPFilterSet, ): status = django_filters.MultipleChoiceFilter( choices=VirtualMachineStatusChoices, @@ -303,3 +306,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet): Q(name__icontains=value) | Q(description__icontains=value) ) + + +class VirtualDiskFilterSet(NetBoxModelFilterSet): + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine', + queryset=VirtualMachine.objects.all(), + label=_('Virtual machine (ID)'), + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label=_('Virtual machine'), + ) + + class Meta: + model = VirtualDisk + fields = ['id', 'name', 'size', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py index 7153453ec..a4ad867d4 100644 --- a/netbox/virtualization/forms/bulk_create.py +++ b/netbox/virtualization/forms/bulk_create.py @@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _ from utilities.forms import BootstrapMixin, form_from_model from utilities.forms.fields import ExpandableNameField -from virtualization.models import VMInterface, VirtualMachine +from virtualization.models import VirtualDisk, VMInterface, VirtualMachine __all__ = ( + 'VirtualDiskBulkCreateForm', 'VMInterfaceBulkCreateForm', ) @@ -30,3 +31,10 @@ class VMInterfaceBulkCreateForm( VirtualMachineBulkAddComponentForm ): replication_fields = ('name',) + + +class VirtualDiskBulkCreateForm( + form_from_model(VirtualDisk, ['size', 'description', 'tags']), + VirtualMachineBulkAddComponentForm +): + replication_fields = ('name',) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index a33ffac53..72990ec76 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -18,6 +18,8 @@ __all__ = ( 'ClusterBulkEditForm', 'ClusterGroupBulkEditForm', 'ClusterTypeBulkEditForm', + 'VirtualDiskBulkEditForm', + 'VirtualDiskBulkRenameForm', 'VirtualMachineBulkEditForm', 'VMInterfaceBulkEditForm', 'VMInterfaceBulkRenameForm', @@ -315,3 +317,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm): queryset=VMInterface.objects.all(), widget=forms.MultipleHiddenInput() ) + + +class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm): + virtual_machine = forms.ModelChoiceField( + label=_('Virtual machine'), + queryset=VirtualMachine.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + size = forms.IntegerField( + required=False, + label=_('Size (GB)') + ) + description = forms.CharField( + label=_('Description'), + max_length=100, + required=False + ) + + model = VirtualDisk + fieldsets = ( + (None, ('size', 'description')), + ) + nullable_fields = ('description',) + + +class VirtualDiskBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=VirtualDisk.objects.all(), + widget=forms.MultipleHiddenInput() + ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 04fe2d7ae..5d44ddceb 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -14,6 +14,7 @@ __all__ = ( 'ClusterImportForm', 'ClusterGroupImportForm', 'ClusterTypeImportForm', + 'VirtualDiskImportForm', 'VirtualMachineImportForm', 'VMInterfaceImportForm', ) @@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm): return True else: return self.cleaned_data['enabled'] + + +class VirtualDiskImportForm(NetBoxModelImportForm): + virtual_machine = CSVModelChoiceField( + label=_('Virtual machine'), + queryset=VirtualMachine.objects.all(), + to_field_name='name' + ) + + class Meta: + model = VirtualDisk + fields = ( + 'virtual_machine', 'name', 'size', 'description', 'tags' + ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 99ac0cb77..5eb3fea1c 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -16,6 +16,7 @@ __all__ = ( 'ClusterFilterForm', 'ClusterGroupFilterForm', 'ClusterTypeFilterForm', + 'VirtualDiskFilterForm', 'VirtualMachineFilterForm', 'VMInterfaceFilterForm', ) @@ -221,3 +222,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): label=_('L2VPN') ) tag = TagFilterField(model) + + +class VirtualDiskFilterForm(NetBoxModelFilterSetForm): + model = VirtualDisk + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + (_('Virtual Machine'), ('virtual_machine_id',)), + (_('Attributes'), ('size',)), + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + label=_('Virtual machine') + ) + size = forms.IntegerField( + label=_('Size (GB)'), + required=False, + min_value=1 + ) + tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 21dbc895a..cbbf5ea66 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -22,6 +22,7 @@ __all__ = ( 'ClusterGroupForm', 'ClusterRemoveDevicesForm', 'ClusterTypeForm', + 'VirtualDiskForm', 'VirtualMachineForm', 'VMInterfaceForm', ) @@ -151,8 +152,12 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): for device in self.cleaned_data.get('devices', []): if device.site != self.cluster.site: raise ValidationError({ - 'devices': _("{} belongs to a different site ({}) than the cluster ({})").format( - device, device.site, self.cluster.site + 'devices': _( + "{device} belongs to a different site ({device_site}) than the cluster ({cluster_site})" + ).format( + device=device, + device_site=device.site, + cluster_site=self.cluster.site ) }) @@ -200,7 +205,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): platform = DynamicModelChoiceField( label=_('Platform'), queryset=Platform.objects.all(), - required=False + required=False, + selector=True ) local_context_data = JSONField( required=False, @@ -235,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): if self.instance.pk: + # Disable the disk field if one or more VirtualDisks have been created + if self.instance.virtualdisks.exists(): + self.fields['disk'].widget.attrs['disabled'] = True + self.fields['disk'].help_text = _("Disk size is managed via the attachment of virtual disks.") + # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [(None, '---------')] @@ -271,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): +# +# Virtual machine components +# + +class VMComponentForm(NetBoxModelForm): virtual_machine = DynamicModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), selector=True ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of VirtualMachine when editing an existing instance + if self.instance.pk: + self.fields['virtual_machine'].disabled = True + + +class VMInterfaceForm(InterfaceCommonForm, VMComponentForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, @@ -343,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'mode': HTMXSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Disable reassignment of VirtualMachine when editing an existing instance - if self.instance.pk: - self.fields['virtual_machine'].disabled = True +class VirtualDiskForm(VMComponentForm): + + fieldsets = ( + (_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')), + ) + + class Meta: + model = VirtualDisk + fields = [ + 'virtual_machine', 'name', 'size', 'description', 'tags', + ] diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 3ea374039..2f6844a5c 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,8 +1,9 @@ from django.utils.translation import gettext_lazy as _ from utilities.forms.fields import ExpandableNameField -from .model_forms import VMInterfaceForm +from .model_forms import VirtualDiskForm, VMInterfaceForm __all__ = ( + 'VirtualDiskCreateForm', 'VMInterfaceCreateForm', ) @@ -15,3 +16,13 @@ class VMInterfaceCreateForm(VMInterfaceForm): class Meta(VMInterfaceForm.Meta): exclude = ('name',) + + +class VirtualDiskCreateForm(VirtualDiskForm): + name = ExpandableNameField( + label=_('Name'), + ) + replication_fields = ('name',) + + class Meta(VirtualDiskForm.Meta): + exclude = ('name',) diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index 88e6aac64..1461faaeb 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -36,3 +36,9 @@ class VirtualizationQuery(graphene.ObjectType): def resolve_vm_interface_list(root, info, **kwargs): return gql_query_optimizer(models.VMInterface.objects.all(), info) + + virtual_disk = ObjectField(VirtualDiskType) + virtual_disk_list = ObjectListField(VirtualDiskType) + + def resolve_virtual_disk_list(root, info, **kwargs): + return gql_query_optimizer(models.VirtualDisk.objects.all(), info) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 96b0fc875..9b97e1dc9 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -8,6 +8,7 @@ __all__ = ( 'ClusterType', 'ClusterGroupType', 'ClusterTypeType', + 'VirtualDiskType', 'VirtualMachineType', 'VMInterfaceType', ) @@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType): def resolve_mode(self, info): return self.mode or None + + +class VirtualDiskType(ComponentObjectType): + + class Meta: + model = models.VirtualDisk + fields = '__all__' + filterset_class = filtersets.VirtualDiskFilterSet + + def resolve_mode(self, info): + return self.mode or None diff --git a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py index 725b73573..abed09d7e 100644 --- a/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py +++ b/netbox/virtualization/migrations/0035_virtualmachine_interface_count.py @@ -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_virtualmachine_counts(apps, schema_editor): VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') - vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True)) - - for vm in vms: - vm.interface_count = vm._interface_count - - VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100) + update_counts(VirtualMachine, 'interface_count', 'interfaces') class Migration(migrations.Migration): diff --git a/netbox/virtualization/migrations/0037_protect_child_interfaces.py b/netbox/virtualization/migrations/0037_protect_child_interfaces.py new file mode 100644 index 000000000..ab6cf0cb3 --- /dev/null +++ b/netbox/virtualization/migrations/0037_protect_child_interfaces.py @@ -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 = [ + ('virtualization', '0036_virtualmachine_config_template'), + ] + + operations = [ + migrations.AlterField( + model_name='vminterface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='child_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/migrations/0038_virtualdisk.py b/netbox/virtualization/migrations/0038_virtualdisk.py new file mode 100644 index 000000000..59d45c975 --- /dev/null +++ b/netbox/virtualization/migrations/0038_virtualdisk.py @@ -0,0 +1,50 @@ +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.json +import utilities.ordering +import utilities.query_functions +import utilities.tracking + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0099_cachedvalue_ordering'), + ('virtualization', '0037_protect_child_interfaces'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='virtual_disk_count', + field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'), + ), + migrations.CreateModel( + name='VirtualDisk', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)), + ('description', models.CharField(blank=True, max_length=200)), + ('size', models.PositiveIntegerField()), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='virtualization.virtualmachine')), + ], + options={ + 'verbose_name': 'virtual disk', + 'verbose_name_plural': 'virtual disks', + 'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')), + 'abstract': False, + }, + bases=(models.Model, utilities.tracking.TrackingModelMixin), + ), + migrations.AddConstraint( + model_name='virtualdisk', + constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'), + ), + ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 6c8fd0c4b..f8acc4c36 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -135,10 +135,9 @@ class Cluster(ContactsMixin, PrimaryModel): # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. if self.pk and self.site: - nonsite_devices = Device.objects.filter(cluster=self).exclude(site=self.site).count() - if nonsite_devices: + if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count(): raise ValidationError({ - 'site': _("{} devices are assigned as hosts for this cluster but are not in site {}").format( - nonsite_devices, self.site - ) + 'site': _( + "{count} devices are assigned as hosts for this cluster but are not in site {site}" + ).format(count=nonsite_devices, site=self.site) }) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index eb6c2a8b0..705419186 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Q +from django.db.models import Q, Sum from django.db.models.functions import Lower from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin from virtualization.choices import * __all__ = ( + 'VirtualDisk', 'VirtualMachine', 'VMInterface', ) @@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima to_model='virtualization.VMInterface', to_field='virtual_machine' ) + virtual_disk_count = CounterCacheField( + to_model='virtualization.VirtualDisk', + to_field='virtual_machine' + ) objects = ConfigContextModelQuerySet.as_manager() @@ -192,6 +197,17 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima ).format(device=self.device, cluster=self.cluster) }) + # Validate aggregate disk size + if self.pk: + total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum'] + if total_disk and self.disk != total_disk: + raise ValidationError({ + 'disk': _( + "The specified disk size ({size}) must match the aggregate size of assigned virtual disks " + "({total_size})." + ).format(size=self.disk, total_size=total_disk) + }) + # Validate primary IP addresses interfaces = self.interfaces.all() if self.pk else None for family in (4, 6): @@ -236,11 +252,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima return None -class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): +# +# VM components +# + + +class ComponentModel(NetBoxModel): + """ + An abstract model inherited by any model which has a parent VirtualMachine. + """ virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, - related_name='interfaces' + related_name='%(class)ss' ) name = models.CharField( verbose_name=_('name'), @@ -257,6 +281,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): max_length=200, blank=True ) + + class Meta: + abstract = True + ordering = ('virtual_machine', CollateAsChar('_name')) + constraints = ( + models.UniqueConstraint( + fields=('virtual_machine', 'name'), + name='%(app_label)s_%(class)s_unique_virtual_machine_name' + ), + ) + + def __str__(self): + return self.name + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.virtual_machine + return objectchange + + @property + def parent_object(self): + return self.virtual_machine + + +class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin): + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='interfaces' # Override ComponentModel + ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -298,20 +358,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): related_query_name='vminterface', ) - class Meta: - ordering = ('virtual_machine', CollateAsChar('_name')) - constraints = ( - models.UniqueConstraint( - fields=('virtual_machine', 'name'), - name='%(app_label)s_%(class)s_unique_virtual_machine_name' - ), - ) + class Meta(ComponentModel.Meta): verbose_name = _('interface') verbose_name_plural = _('interfaces') - def __str__(self): - return self.name - def get_absolute_url(self): return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) @@ -359,15 +409,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin): ).format(untagged_vlan=self.untagged_vlan) }) - def to_objectchange(self, action): - objectchange = super().to_objectchange(action) - objectchange.related_object = self.virtual_machine - return objectchange - - @property - def parent_object(self): - return self.virtual_machine - @property def l2vpn_termination(self): return self.l2vpn_terminations.first() + + +class VirtualDisk(ComponentModel, TrackingModelMixin): + size = models.PositiveIntegerField( + verbose_name=_('size (GB)'), + ) + + class Meta(ComponentModel.Meta): + verbose_name = _('virtual disk') + verbose_name_plural = _('virtual disks') + + def get_absolute_url(self): + return reverse('virtualization:virtualdisk', args=[self.pk]) diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py index 643a9f6de..9e67a0af2 100644 --- a/netbox/virtualization/search.py +++ b/netbox/virtualization/search.py @@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description') @register_search @@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description') @register_search @@ -51,3 +55,14 @@ class VMInterfaceIndex(SearchIndex): ('description', 500), ('mtu', 2000), ) + display_attrs = ('virtual_machine', 'description') + + +@register_search +class VirtualDiskIndex(SearchIndex): + model = models.VirtualDisk + fields = ( + ('name', 100), + ('description', 500), + ) + display_attrs = ('virtual_machine', 'description') diff --git a/netbox/virtualization/signals.py b/netbox/virtualization/signals.py new file mode 100644 index 000000000..06f172179 --- /dev/null +++ b/netbox/virtualization/signals.py @@ -0,0 +1,16 @@ +from django.db.models import Sum +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import VirtualDisk, VirtualMachine + + +@receiver((post_delete, post_save), sender=VirtualDisk) +def update_virtualmachine_disk(instance, **kwargs): + """ + When a VirtualDisk has been modified, update the aggregate disk_size value of its VM. + """ + vm = instance.virtual_machine + VirtualMachine.objects.filter(pk=vm.pk).update( + disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum'] + ) diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index f8473df1e..88627462a 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -4,10 +4,12 @@ from django.utils.translation import gettext_lazy as _ from dcim.tables.devices import BaseInterfaceTable from netbox.tables import NetBoxTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin -from virtualization.models import VirtualMachine, VMInterface +from virtualization.models import VirtualDisk, VirtualMachine, VMInterface __all__ = ( + 'VirtualDiskTable', 'VirtualMachineTable', + 'VirtualMachineVirtualDiskTable', 'VirtualMachineVMInterfaceTable', 'VMInterfaceTable', ) @@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable) interface_count = tables.Column( verbose_name=_('Interfaces') ) + virtual_disk_count = tables.Column( + verbose_name=_('Virtual Disks') + ) config_template = tables.Column( verbose_name=_('Config Template'), linkify=True @@ -155,3 +160,39 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): row_attrs = { 'data-name': lambda record: record.name, } + + +class VirtualDiskTable(NetBoxTable): + virtual_machine = tables.Column( + verbose_name=_('Virtual Machine'), + linkify=True + ) + name = tables.Column( + verbose_name=_('Name'), + linkify=True + ) + tags = columns.TagColumn( + url_name='virtualization:virtualdisk_list' + ) + + class Meta(NetBoxTable.Meta): + model = VirtualDisk + fields = ( + 'pk', 'id', 'virtual_machine', 'name', 'size', 'description', 'tags', + ) + default_columns = ('pk', 'name', 'virtual_machine', 'size', 'description') + row_attrs = { + 'data-name': lambda record: record.name, + } + + +class VirtualMachineVirtualDiskTable(VirtualDiskTable): + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + ) + + class Meta(VirtualDiskTable.Meta): + fields = ( + 'pk', 'id', 'name', 'size', 'description', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'size', 'description') diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index b2ae68860..819ce54e4 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -3,10 +3,11 @@ from rest_framework import status from dcim.choices import InterfaceModeChoices from dcim.models import Site +from extras.models import ConfigTemplate from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import * class AppTest(APITestCase): @@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_render_config(self): + configtemplate = ConfigTemplate.objects.create( + name='Config Template 1', + template_code='Config for virtual machine {{ virtualmachine.name }}' + ) + + vm = VirtualMachine.objects.first() + vm.config_template = configtemplate + vm.save() + + self.add_permissions('virtualization.add_virtualmachine') + url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/' + response = self.client.post(url, {}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}') + class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface @@ -239,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): - - clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1') - cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype) - virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1') + virtualmachine = create_test_virtualmachine('Virtual Machine 1') interfaces = ( VMInterface(virtual_machine=virtualmachine, name='Interface 1'), @@ -293,3 +307,67 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'vrf': vrfs[2].pk, }, ] + + def test_bulk_delete_child_interfaces(self): + interface1 = VMInterface.objects.get(name='Interface 1') + virtual_machine = interface1.virtual_machine + self.add_permissions('virtualization.delete_vminterface') + + # Create a child interface + child = VMInterface.objects.create( + virtual_machine=virtual_machine, + name='Interface 1A', + parent=interface1 + ) + self.assertEqual(virtual_machine.interfaces.count(), 4) + + # Attempt to delete only the parent interface + url = self._get_detail_url(interface1) + self.client.delete(url, **self.header) + self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = [ + {"id": interface1.pk}, + {"id": child.pk}, + ] + self.client.delete(self._get_list_url(), data, format='json', **self.header) + self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted + + +class VirtualDiskTest(APIViewTestCases.APIViewTestCase): + model = VirtualDisk + brief_fields = ['display', 'id', 'name', 'size', 'url', 'virtual_machine'] + bulk_update_data = { + 'size': 888, + } + graphql_base_name = 'virtual_disk' + + @classmethod + def setUpTestData(cls): + virtualmachine = create_test_virtualmachine('Virtual Machine 1') + + disks = ( + VirtualDisk(virtual_machine=virtualmachine, name='Disk 1', size=10), + VirtualDisk(virtual_machine=virtualmachine, name='Disk 2', size=20), + VirtualDisk(virtual_machine=virtualmachine, name='Disk 3', size=30), + ) + VirtualDisk.objects.bulk_create(disks) + + cls.create_data = [ + { + 'virtual_machine': virtualmachine.pk, + 'name': 'Disk 4', + 'size': 10, + }, + { + 'virtual_machine': virtualmachine.pk, + 'name': 'Disk 5', + 'size': 20, + }, + { + 'virtual_machine': virtualmachine.pk, + 'name': 'Disk 6', + 'size': 30, + }, + ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d474af21a..8e2e723bd 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -6,7 +6,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import * class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ipaddresses = ( IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]), IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]), + IPAddress(address='192.0.2.3/24', assigned_object=None), + IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]), + IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]), + IPAddress(address='2001:db8::3/64', assigned_object=None), ) IPAddress.objects.bulk_create(ipaddresses) - VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0]) - VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1]) + VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3]) + VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4]) def test_name(self): params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']} @@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_primary_ip4(self): + addresses = IPAddress.objects.filter(address__family=4) + params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip4_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + + def test_primary_ip6(self): + addresses = IPAddress.objects.filter(address__family=6) + params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'primary_ip6_id': [addresses[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) + class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VMInterface.objects.all() @@ -516,3 +534,46 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VirtualDisk.objects.all() + filterset = VirtualDiskFilterSet + + @classmethod + def setUpTestData(cls): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) + + vms = ( + VirtualMachine(name='Virtual Machine 1', cluster=cluster), + VirtualMachine(name='Virtual Machine 2', cluster=cluster), + VirtualMachine(name='Virtual Machine 3', cluster=cluster), + ) + VirtualMachine.objects.bulk_create(vms) + + disks = ( + VirtualDisk(virtual_machine=vms[0], name='Disk 1', size=1, description='A'), + VirtualDisk(virtual_machine=vms[1], name='Disk 2', size=2, description='B'), + VirtualDisk(virtual_machine=vms[2], name='Disk 3', size=3, description='C'), + ) + VirtualDisk.objects.bulk_create(disks) + + def test_virtual_machine(self): + vms = VirtualMachine.objects.all()[:2] + params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_machine': [vms[0].name, vms[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Disk 1', 'Disk 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_size(self): + params = {'size': [1, 2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 782b9f07f..c94ff930e 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -90,3 +90,28 @@ class VirtualMachineTestCase(TestCase): # Uniqueness validation for name should ignore case with self.assertRaises(ValidationError): vm2.full_clean() + + def test_disk_size(self): + vm = VirtualMachine( + cluster=Cluster.objects.first(), + name='Virtual Machine 1' + ) + vm.save() + vm.refresh_from_db() + self.assertEqual(vm.disk, None) + + # Create two VirtualDisks + VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 1', size=10) + VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 2', size=10) + vm.refresh_from_db() + self.assertEqual(vm.disk, 20) + + # Delete one VirtualDisk + VirtualDisk.objects.first().delete() + vm.refresh_from_db() + self.assertEqual(vm.disk, 10) + + # Attempt to manually overwrite the aggregate disk size + vm.disk = 30 + with self.assertRaises(ValidationError): + vm.full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index a5d831d7e..ed6bef1e4 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,9 +5,9 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags, create_test_device +from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine from virtualization.choices import * -from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from virtualization.models import * class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @@ -374,3 +374,83 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], } + + def test_bulk_delete_child_interfaces(self): + interface1 = VMInterface.objects.get(name='Interface 1') + virtual_machine = interface1.virtual_machine + self.add_permissions('virtualization.delete_vminterface') + + # Create a child interface + child = VMInterface.objects.create( + virtual_machine=virtual_machine, + name='Interface 1A', + parent=interface1 + ) + self.assertEqual(virtual_machine.interfaces.count(), 4) + + # Attempt to delete only the parent interface + data = { + 'confirm': True, + } + self.client.post(self._get_url('delete', interface1), data) + self.assertEqual(virtual_machine.interfaces.count(), 4) # Parent was not deleted + + # Attempt to bulk delete parent & child together + data = { + 'pk': [interface1.pk, child.pk], + 'confirm': True, + '_confirm': True, # Form button + } + self.client.post(self._get_url('bulk_delete'), data) + self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted + + +class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase): + model = VirtualDisk + validation_excluded_fields = ('name',) + + @classmethod + def setUpTestData(cls): + virtualmachine = create_test_virtualmachine('Virtual Machine 1') + + disks = VirtualDisk.objects.bulk_create([ + VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 1', size=10), + VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 2', size=10), + VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 3', size=10), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'virtual_machine': virtualmachine.pk, + 'name': 'Virtual Disk X', + 'size': 20, + 'description': 'New description', + 'tags': [t.pk for t in tags], + } + + cls.bulk_create_data = { + 'virtual_machine': virtualmachine.pk, + 'name': 'Virtual Disk [4-6]', + 'size': 10, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + f"virtual_machine,name,size,description", + f"Virtual Machine 1,Disk 4,20,Fourth", + f"Virtual Machine 1,Disk 5,20,Fifth", + f"Virtual Machine 1,Disk 6,20,Sixth", + ) + + cls.csv_update_data = ( + f"id,name,size", + f"{disks[0].pk},disk1,20", + f"{disks[1].pk},disk2,20", + f"{disks[2].pk},disk3,20", + ) + + cls.bulk_edit_data = { + 'size': 30, + 'description': 'New description', + } diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 9e5d5a670..78f88260a 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -48,4 +48,13 @@ urlpatterns = [ path('interfaces//', include(get_model_urls('virtualization', 'vminterface'))), path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'), + # Virtual disks + path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'), + path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'), + path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'), + path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'), + path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'), + path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'), + path('disks//', include(get_model_urls('virtualization', 'virtualdisk'))), + path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index cbe953040..6019fc227 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -1,5 +1,4 @@ import traceback -from collections import defaultdict from django.contrib import messages from django.db import transaction @@ -16,12 +15,14 @@ from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress from ipam.tables import InterfaceVLANTable +from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from tenancy.views import ObjectContactsView +from utilities.query_functions import CollateAsChar from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from . import filtersets, forms, tables -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface +from .models import * # @@ -199,13 +200,13 @@ class ClusterDevicesView(generic.ObjectChildrenView): table = DeviceTable filterset = DeviceFilterSet template_name = 'virtualization/cluster/devices.html' - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_remove_devices') - action_perms = defaultdict(set, **{ + actions = { 'add': {'add'}, 'import': {'add'}, + 'export': {'view'}, 'bulk_edit': {'change'}, 'bulk_remove_devices': {'change'}, - }) + } tab = ViewTab( label=_('Devices'), badge=lambda obj: obj.devices.count(), @@ -359,20 +360,16 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): table = tables.VirtualMachineVMInterfaceTable filterset = filtersets.VMInterfaceFilterSet template_name = 'virtualization/virtualmachine/interfaces.html' + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } tab = ViewTab( label=_('Interfaces'), badge=lambda obj: obj.interface_count, permission='virtualization.view_vminterface', weight=500 ) - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename') - action_perms = defaultdict(set, **{ - 'add': {'add'}, - 'import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - 'bulk_rename': {'change'}, - }) def get_children(self, request, parent): return parent.interfaces.restrict(request.user, 'view').prefetch_related( @@ -381,6 +378,28 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView): ) +@register_model_view(VirtualMachine, 'disks') +class VirtualMachineVirtualDisksView(generic.ObjectChildrenView): + queryset = VirtualMachine.objects.all() + child_model = VirtualDisk + table = tables.VirtualMachineVirtualDiskTable + filterset = filtersets.VirtualDiskFilterSet + template_name = 'virtualization/virtualmachine/virtual_disks.html' + tab = ViewTab( + label=_('Virtual Disks'), + badge=lambda obj: obj.virtual_disk_count, + permission='virtualization.view_virtual_disk', + weight=500 + ) + actions = { + **DEFAULT_ACTION_PERMISSIONS, + 'bulk_rename': {'change'}, + } + + def get_children(self, request, parent): + return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags') + + @register_model_view(VirtualMachine, 'configcontext', path='config-context') class VirtualMachineConfigContextView(ObjectConfigContextView): queryset = VirtualMachine.objects.annotate_config_context_data() @@ -397,7 +416,6 @@ class VirtualMachineRenderConfigView(generic.ObjectView): template_name = 'virtualization/virtualmachine/render_config.html' tab = ViewTab( label=_('Render Config'), - permission='extras.view_configtemplate', weight=2100 ) @@ -554,11 +572,68 @@ class VMInterfaceBulkRenameView(generic.BulkRenameView): class VMInterfaceBulkDeleteView(generic.BulkDeleteView): - queryset = VMInterface.objects.all() + # Ensure child interfaces are deleted prior to their parents + queryset = VMInterface.objects.order_by('virtual_machine', 'parent', CollateAsChar('_name')) filterset = filtersets.VMInterfaceFilterSet table = tables.VMInterfaceTable +# +# Virtual disks +# + +class VirtualDiskListView(generic.ObjectListView): + queryset = VirtualDisk.objects.all() + filterset = filtersets.VirtualDiskFilterSet + filterset_form = forms.VirtualDiskFilterForm + table = tables.VirtualDiskTable + + +@register_model_view(VirtualDisk) +class VirtualDiskView(generic.ObjectView): + queryset = VirtualDisk.objects.all() + + +class VirtualDiskCreateView(generic.ComponentCreateView): + queryset = VirtualDisk.objects.all() + form = forms.VirtualDiskCreateForm + model_form = forms.VirtualDiskForm + + +@register_model_view(VirtualDisk, 'edit') +class VirtualDiskEditView(generic.ObjectEditView): + queryset = VirtualDisk.objects.all() + form = forms.VirtualDiskForm + + +@register_model_view(VirtualDisk, 'delete') +class VirtualDiskDeleteView(generic.ObjectDeleteView): + queryset = VirtualDisk.objects.all() + + +class VirtualDiskBulkImportView(generic.BulkImportView): + queryset = VirtualDisk.objects.all() + model_form = forms.VirtualDiskImportForm + + +class VirtualDiskBulkEditView(generic.BulkEditView): + queryset = VirtualDisk.objects.all() + filterset = filtersets.VirtualDiskFilterSet + table = tables.VirtualDiskTable + form = forms.VirtualDiskBulkEditForm + + +class VirtualDiskBulkRenameView(generic.BulkRenameView): + queryset = VirtualDisk.objects.all() + form = forms.VirtualDiskBulkRenameForm + + +class VirtualDiskBulkDeleteView(generic.BulkDeleteView): + queryset = VirtualDisk.objects.all() + filterset = filtersets.VirtualDiskFilterSet + table = tables.VirtualDiskTable + + # # Bulk Device component creation # @@ -575,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView): def get_required_permission(self): return f'virtualization.add_vminterface' + + +class VirtualMachineBulkAddVirtualDiskView(generic.BulkComponentCreateView): + parent_model = VirtualMachine + parent_field = 'virtual_machine' + form = forms.VirtualDiskBulkCreateForm + queryset = VirtualDisk.objects.all() + model_form = forms.VirtualDiskForm + filterset = filtersets.VirtualMachineFilterSet + table = tables.VirtualMachineTable + default_return_url = 'virtualization:virtualmachine_list' + + def get_required_permission(self): + return f'virtualization.add_virtualdisk' diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 1103cec37..a6cc9f535 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -1,6 +1,6 @@ from rest_framework.routers import APIRootView -from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from wireless import filtersets from wireless.models import * from . import serializers @@ -14,7 +14,7 @@ class WirelessRootView(APIRootView): return 'Wireless' -class WirelessLANGroupViewSet(NetBoxModelViewSet): +class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet): queryset = WirelessLANGroup.objects.add_related_count( WirelessLANGroup.objects.all(), WirelessLAN, diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 046918535..0b114f85f 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from mptt.models import MPTTModel from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES @@ -214,14 +213,14 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): if self.interface_a.type not in WIRELESS_IFACE_TYPES: raise ValidationError({ 'interface_a': _( - "{type_display} is not a wireless interface." - ).format(type_display=self.interface_a.get_type_display()) + "{type} is not a wireless interface." + ).format(type=self.interface_a.get_type_display()) }) if self.interface_b.type not in WIRELESS_IFACE_TYPES: raise ValidationError({ 'interface_a': _( - "{type_display} is not a wireless interface." - ).format(type_display=self.interface_b.get_type_display()) + "{type} is not a wireless interface." + ).format(type=self.interface_b.get_type_display()) }) def save(self, *args, **kwargs): diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index 1f8097cd7..c8ac023cc 100644 --- a/netbox/wireless/search.py +++ b/netbox/wireless/search.py @@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex): ('auth_psk', 2000), ('comments', 5000), ) + display_attrs = ('group', 'status', 'vlan', 'tenant', 'description') @register_search @@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex): ('slug', 110), ('description', 500), ) + display_attrs = ('description',) @register_search @@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex): ('auth_psk', 2000), ('comments', 5000), ) + display_attrs = ('status', 'tenant', 'description') diff --git a/requirements.txt b/requirements.txt index b313f98d6..45fb12f80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,35 +1,35 @@ -bleach==6.0.0 -Django==4.2.4 -django-cors-headers==4.2.0 +bleach==6.1.0 +Django==4.2.7 +django-cors-headers==4.3.0 django-debug-toolbar==4.2.0 -django-filter==23.2 +django-filter==23.3 django-graphiql-debug-toolbar==0.2.0 -django-mptt==0.14 +django-mptt==0.14.0 django-pglocks==1.0.4 django-prometheus==2.3.1 -django-redis==5.3.0 -django-rich==1.7.0 +django-redis==5.4.0 +django-rich==1.8.0 django-rq==2.8.1 django-tables2==2.6.0 django-taggit==4.0.0 -django-timezone-field==5.1 +django-timezone-field==6.0.1 djangorestframework==3.14.0 -drf-spectacular==0.26.4 -drf-spectacular-sidecar==2023.8.1 +drf-spectacular==0.26.5 +drf-spectacular-sidecar==2023.10.1 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.2.5 -mkdocstrings[python-legacy]==0.22.0 -netaddr==0.8.0 -Pillow==10.0.0 -psycopg[binary,pool]==3.1.10 +mkdocs-material==9.4.8 +mkdocstrings[python-legacy]==0.23.0 +netaddr==0.9.0 +Pillow==10.1.0 +psycopg[binary,pool]==3.1.12 PyYAML==6.0.1 -sentry-sdk==1.30.0 -social-auth-app-django==5.2.0 -social-auth-core[openidconnect]==4.4.2 +requests==2.31.0 +social-auth-app-django==5.4.0 +social-auth-core[openidconnect]==4.5.0 svgwrite==1.4.3 tablib==3.5.0 tzdata==2023.3