Compare commits

...

60 Commits

Author SHA1 Message Date
Jeremy Stretch
952be24365 Merge pull request #13838 from netbox-community/develop
Release v3.6.2
2023-09-20 15:29:06 -04:00
Jeremy Stretch
b57a47475d Release v3.6.2 2023-09-20 15:05:29 -04:00
Jeremy Stretch
4f05cf55a5 Changelog for #11617, #12685, #13245, #13653, #13757, #13809, #13813, #13818 2023-09-20 14:47:47 -04:00
Jeremy Stretch
5dcf8502af Grammar fix 2023-09-20 14:44:04 -04:00
Jeremy Stretch
7a21541ed6 Plug NetBox Cloud in installation docs 2023-09-20 14:43:12 -04:00
Jeremy Stretch
ae4ea3443e Fixes #11617: Check for invalid CSV headers during bulk import (#13826)
* Fixes #11617: Check for invalid CSV headers during bulk import

* Add test for CSV import header validation
2023-09-20 14:40:27 -04:00
Arthur Hanson
f5dd7d853a 13809 fix ConfigRevision edit if custom validators (#13825)
* 13809 fix ConfigRevision edit, check if custom validator JSON serializable

* 13809 check json rendering for all fields

* Refactor field initialization logic to more cleanly handle statically configured values

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-20 14:11:25 -04:00
Arthur Hanson
a1e42dad10 13653 darken code color to work in light and dark modes (#13827)
* 13653 darken code color to work in light and dark modes

* 13809 changed to use mx-1 on code block
2023-09-20 14:08:12 -04:00
Arthur Hanson
6e4b4a553b 12685 use markdown for custom fields added to form (#13828)
* 12685 use markdown for custom fields added to form

* 13809 change markdown to use utilities

* Add help_text for CustomField description indicating Markdown support

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-20 14:06:04 -04:00
Arthur Hanson
7a410dfd00 13813 fix virtual chassis member count (#13823)
* 13813 fix virtual chassis member count

* 13813 add test
2023-09-20 13:57:35 -04:00
bluikko
6fb980349f 13245 add QSFP112 and OSFP-RHS interface choices 2023-09-20 10:10:51 -04:00
Arthur Hanson
8e251ac33c 13757 Fix ConfigContext reference to DeviceType (#13804)
* 13757 do prefetch to work around Django issue with vars in init (DeviceType)

* 13757 use self.__dict to access vars in init

* 13757 change test
2023-09-20 09:56:52 -04:00
Jeremy Stretch
35bcc2ce9d Revert "Fixes #13741: Enforce unique names for inventory items with no parent item"
This reverts commit 68966db23d.
2023-09-20 08:44:25 -04:00
Arthur
69215c411b 13818 add tags to l2vpntermination edit form 2023-09-19 17:42:19 -04:00
Jeremy Stretch
a08b5793f6 Correct example default dashboard config 2023-09-19 14:40:52 -04:00
Jeremy Stretch
252bf03525 Fixes #13802: Restore 'description' header text for custom fields 2023-09-18 13:35:54 -04:00
Jeremy Stretch
b9b9bb134f Changelog for #13741, #13745, #13756, #13782 2023-09-18 11:12:27 -04:00
Jeremy Stretch
68966db23d Fixes #13741: Enforce unique names for inventory items with no parent item 2023-09-18 11:10:00 -04:00
Jeremy Stretch
9aa7444bf9 Fixes #13782: Fix tag exclusion support for contact assignments 2023-09-18 11:08:49 -04:00
Arthur Hanson
b0541be107 13745 device type migration (#13747)
* 13745 update migrations to use batch_size

* 13745 update migrations to use subquery update

* 13745 refactor and update other counter migrations
2023-09-18 09:59:26 -04:00
Abhimanyu Saharan
3d1f668235 Disables module_status ordering (#13761)
* disables module_status ordering #13756

* Set accessor for module status value

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-18 09:09:29 -04:00
Jeremy Stretch
940c947d3f Changelog for #11209, #12219, #13727, #13563, #13767, #13791 2023-09-18 08:49:08 -04:00
Jeremy Stretch
c7dd4206c8 Fixes #13727: Fix exception when viewing rendered config for VM without a role assigned 2023-09-18 08:44:42 -04:00
Per von Zweigbergk
79bf12a8fe 13791 rename whitespace fix (#13793)
* Add test for bug #13791
https://github.com/netbox-community/netbox/issues/13791

* Fix #13791 by disabling striping on find and replace fields of BulkRenameForm
2023-09-18 08:33:29 -04:00
Jeremy Stretch
2dfbd72f10 Fixes #13767: Fix support for comments when creating a new service via web UI 2023-09-15 10:33:54 -04:00
Arthur
487827c776 13768 fix typo 2023-09-15 09:40:27 -04:00
Jeremy Stretch
6939bf8aed Fixes #12219: Ensure dashboard widget heading text has sufficient contrast (#13753)
* Fixes #12219: Ensure dashboard widget heading text has sufficient contrast in both light & dark modes

* Change foreground color for teal background
2023-09-13 10:56:03 -04:00
Daniel Sheppard
e4cb0c3cc2 Fixes #11209 - Fix PrefixIPAddress view with saved sort preferences (#12820)
* Fixes #11209 - Do not add available ips when IPAddressTable sort preferences are saved

* Refine check to account scenario right after clearing ordering string

* Introduce get_table_ordering() utility to determine intended ordering given a request

* Apply fix to VLAN ranges as well

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-13 10:51:24 -04:00
Daniel W. Anner
cf2f39a0a8 Documentation: LDAP Update for Active Directory (#13716)
* Adding documentation to 6-LDAP to display how to allow Active Directory logins with or without the user UPN suffix.

* Correcting misspellings and clarifying explanations

* Updating sections to include sample template

* Misc revisions

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-13 08:44:52 -04:00
Abhimanyu Saharan
b7cfb2f7d9 Adds csv dialect detection to bulk import view (#13563)
* adds csv dialect detection to bulk import view #13239

* adds sane delimiters for dialect detection #13239

* adds csv delimiter tests #13239

* adds csv delimiter on the form

* pass delimiter to clean_csv method #13239

* fix tests for csv import #13239

* fix tests for csv import #13239

* fix tests for csv import #13239

* fix tests for csv import #13239

* Improve auto-detection of import data format

* Misc cleanup

* Include tab as a supported delimiting character for auto-detection

* Move delimiting chars to a separate constant for easy reference

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-12 16:48:40 -04:00
Jeremy Stretch
39cb9c32d6 Clean up blocktrans template tags (i18n) 2023-09-11 16:17:02 -04:00
Jeremy Stretch
75b71890a4 Misc i18n cleanup 2023-09-11 15:59:50 -04:00
Jeremy Stretch
2ffa6d0188 Fixes #13701: Correct display of power feed legs under device view 2023-09-11 14:16:29 -04:00
Jeremy Stretch
026386db50 Fixes #13706: Restore extra filters dropdown on device interfaces list 2023-09-11 14:13:55 -04:00
Jeremy Stretch
b5125e512f Fixes #13721: Filter VLAN choices by selected site (if any) when creating a prefix 2023-09-11 13:52:19 -04:00
Jeremy Stretch
a8a36c0a8f PRVB 2023-09-06 14:26:19 -04:00
Jeremy Stretch
99ab054ea0 Merge pull request #13705 from netbox-community/develop
Release v3.6.1
2023-09-06 14:23:36 -04:00
Jeremy Stretch
90ab4b3c86 Release v3.6.1 2023-09-06 14:04:57 -04:00
Arthur Hanson
bb6b4d01c1 12553 prefix serializer to IPAddress (#13592)
* 12553 prefix serializer to IPAddress

* Introduce IPNetworkField to handle prefix serialization

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-06 10:49:40 -04:00
Daniel Sheppard
2d1457b94b Fixes: #13682 - Fix custom field exceptions and validation (#13685)
* Fixes: #13682 - Fix custom field exceptions and validation

* Add tests

* Remove default setting for multi-select/multi-object and return slice of choices and annotate.

* Remove redundant default choice valiadtion; introduce values property on CustomFieldChoiceSet

* Refactor test

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-06 10:47:18 -04:00
Arthur Hanson
9d851924c8 13674 fix ReportSerializer (#13688)
* 13674 fix ReportSerializer

* Remove test_methods attr from Report class

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-06 08:44:25 -04:00
Jeremy Stretch
9be5918c83 Fixes #13684: Enable modying the configuration when maintenance mode is enabled 2023-09-05 14:09:38 -04:00
Jeremy Stretch
6db6616892 Changelog for #12870, #13444, #13596, #13642, #13657 2023-09-01 17:14:59 -04:00
Abhimanyu Saharan
004daca862 Adds rename button on the list page for device components (#13564)
* adds interface rename button on the list page #13444

* adds rename view on all device components #13564

* Condense component views to a single template

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-09-01 16:58:31 -04:00
Jeremy Stretch
559f65f6b2 Add #12906 to v3.6.0 changelog 2023-09-01 13:22:07 -04:00
Jeremy Stretch
c38884fa11 Add description & expires fields to token test 2023-09-01 12:33:02 -04:00
Abhimanyu Saharan
7848beedce adds additional parameters for token provision api #12870 2023-09-01 12:33:02 -04:00
Jeremy Stretch
296166da95 Fixes #13656: Correct decoding of BinaryField content for Django 4.2 2023-09-01 11:06:19 -04:00
Jeremy Stretch
679cc8fdda Fixes #13596: Always display "render config" tab for devices & VMs 2023-08-31 14:36:03 -04:00
Jeremy Stretch
0cdc26e013 Fixes #13642: Move migration logic overrides from individual mgmt commands to core 2023-08-31 14:34:26 -04:00
Jeremy Stretch
2503568875 Changelog for #13619, #13620, #13622, #13628, #13632, #13638 2023-08-31 12:23:59 -04:00
Jeremy Stretch
78966e12a9 Fixes #13620: Show admin menu items only for staff users 2023-08-31 12:20:46 -04:00
Jeremy Stretch
f962fb3b53 Closes #13638: Add optional staff_only attribute to MenuItem (#13639)
* Closes #13638: Add optional staff_only attribute to MenuItem

* Add missing file

* Add release note
2023-08-31 11:23:44 -04:00
Jeremy Stretch
2544e2bf18 Fixes #13622: Fix exception when viewing current config and no revisions have been created 2023-08-31 11:11:56 -04:00
Jeremy Stretch
06f2c6f867 Fixes #13632: Avoid raising exception when checking if FHRP group IP address is primary 2023-08-31 11:09:49 -04:00
Abhimanyu Saharan
272d2c54d4 removes napalm references #13628 2023-08-31 09:54:35 -04:00
Jeremy Stretch
cb93abb0f4 Fixes #13626: Correct filtering of recent activity list under user view 2023-08-31 08:19:17 -04:00
Jeremy Stretch
316d991b33 Fixes #13630: Fix display of active status under user view 2023-08-31 08:16:11 -04:00
Jamie (Bear) Murphy
46f734eba2 fix error for is_oob_ip for non-device parents (#13621)
* fix error for is_oob_ip for non-device parents

* adjust oob_ip_id check to use hasattr
2023-08-31 07:57:14 -04:00
Jeremy Stretch
671a56100a PRVB 2023-08-30 14:57:16 -04:00
118 changed files with 1128 additions and 509 deletions

View File

@@ -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.2
validations:
required: true
- type: dropdown

View File

@@ -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.2
validations:
required: true
- type: dropdown

View File

@@ -1,6 +1,6 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
<p>The premier source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div>

View File

@@ -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",

View File

@@ -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': {

View File

@@ -97,7 +97,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement.
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
```
@@ -107,7 +107,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
<h5 class="card-header">{% trans "Circuit List" %}</h5>
{# A longer string with a context variable #}
{% blocktrans with count=object.circuits.count %}
{% blocktrans trimmed with count=object.circuits.count %}
There are {count} circuits. Would you like to continue?
{% endblocktrans %}
```

View File

@@ -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.

View File

@@ -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`.

View File

@@ -1,5 +1,8 @@
# Installation
!!! info "NetBox Cloud"
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

View File

@@ -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.

View File

@@ -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

View File

@@ -1,10 +1,68 @@
# NetBox v3.6
## 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
---
## v3.6.0 (2023-08-30)
### Breaking Changes
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `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.
@@ -85,8 +143,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

View File

@@ -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):

View File

@@ -14,8 +14,8 @@ class DataSourceTypeChoices(ChoiceSet):
CHOICES = (
(LOCAL, _('Local'), 'gray'),
(GIT, _('Git'), 'blue'),
(AMAZON_S3, _('Amazon S3'), 'blue'),
(GIT, 'Git', 'blue'),
(AMAZON_S3, 'Amazon S3', 'blue'),
)

View File

@@ -81,13 +81,13 @@ class GitBackend(DataBackend):
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,

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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
@@ -152,4 +153,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
)

View File

@@ -787,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
]
class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.JSONField()
#
# Device components
#

View File

@@ -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)'),

View File

@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
)
help_texts = {
'time_zone': mark_safe(
_('Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)')
'{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
_('Time zone'), _('available options')
)
)
}
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
help_text=mark_safe(
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
)
)
type = CSVChoiceField(
label=_('Type'),
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_device = CSVModelChoiceField(
label=_('Side a device'),
label=_('Side A device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Side A device')
help_text=_('Device name')
)
side_a_type = CSVContentTypeField(
label=_('Side a type'),
label=_('Side A type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side A type')
help_text=_('Termination type')
)
side_a_name = forms.CharField(
label=_('Side a name'),
help_text=_('Side A component name')
label=_('Side A name'),
help_text=_('Termination name')
)
# Termination B
side_b_device = CSVModelChoiceField(
label=_('Side b device'),
label=_('Side B device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Side B device')
help_text=_('Device name')
)
side_b_type = CSVContentTypeField(
label=_('Side b type'),
label=_('Side B type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side B type')
help_text=_('Termination type')
)
side_b_name = forms.CharField(
label=_('Side b name'),
help_text=_('Side B component name')
label=_('Side B name'),
help_text=_('Termination name')
)
# Cable attributes
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
def _clean_side(self, side):

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):

View File

@@ -98,10 +98,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

View File

@@ -89,7 +89,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)

View File

@@ -86,7 +86,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:
@@ -799,9 +799,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 +889,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 +1067,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)
})

View File

@@ -205,11 +205,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])

View File

@@ -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

View File

@@ -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

View File

@@ -871,8 +871,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):

View File

@@ -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,
}
}

View File

@@ -2033,7 +2033,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 +2184,15 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(ConsolePort)
@@ -2248,6 +2256,15 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(ConsoleServerPort)
@@ -2311,6 +2328,15 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(PowerPort)
@@ -2374,6 +2400,15 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(PowerOutlet)
@@ -2437,6 +2472,15 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(Interface)
@@ -2548,6 +2592,15 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(FrontPort)
@@ -2611,6 +2664,15 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(RearPort)
@@ -2674,6 +2736,15 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(ModuleBay)
@@ -2729,6 +2800,15 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(DeviceBay)
@@ -2853,6 +2933,15 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
template_name = 'dcim/component_list.html'
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'},
})
@register_model_view(InventoryItem)

View File

@@ -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)

View File

@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
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
@@ -115,6 +116,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 {

View File

@@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
model = Tag
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}

View File

@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
__all__ = (
'CustomFieldsMixin',
'SavedFiltersMixin',
'TagsMixin',
)
@@ -72,3 +73,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)

View File

@@ -4,6 +4,7 @@ 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
@@ -75,13 +76,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 +93,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:'
) + ' <code>choice1,First Choice</code>')
)
class Meta:
@@ -325,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
required=False
)
tenant_groups = DynamicModelMultipleChoiceField(
label=_('Tenat groups'),
label=_('Tenant groups'),
queryset=TenantGroup.objects.all(),
required=False
)
@@ -515,22 +518,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 += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').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 += '<br />' # Line break
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '&mdash;')
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)

View File

@@ -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}'}
)

View File

@@ -28,6 +28,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__ = (
@@ -219,7 +220,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):
@@ -282,7 +283,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
raise ValidationError({
'default': _(
'Invalid default value "{default}": {message}'
).format(default=self.default, message=self.message)
).format(default=self.default, message=err.message)
})
# Minimum/maximum values can be set only for numeric fields
@@ -317,14 +318,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:
@@ -506,7 +499,7 @@ 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:
@@ -650,19 +643,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 +743,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."))

View File

@@ -723,6 +723,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 +735,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):

View File

@@ -36,9 +36,10 @@ class PluginMenuItem:
permissions = []
buttons = []
def __init__(self, link, link_text, permissions=None, buttons=None):
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.")

View File

@@ -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)

View File

@@ -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
@@ -434,7 +434,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 +444,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 +452,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}',
}

View File

@@ -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)

View File

@@ -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']
#

View File

@@ -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(
@@ -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):

View File

@@ -290,8 +290,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)
@@ -554,25 +554,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
@@ -892,7 +880,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 +888,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

View File

@@ -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
@@ -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
#

View File

@@ -158,39 +158,6 @@ PARAMS = (
},
),
# 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'),
default={},
description=_("Additional arguments to pass when invoking a NAPALM driver (as JSON data)"),
field=forms.JSONField,
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
),
# User preferences
ConfigParam(
name='DEFAULT_USER_PREFERENCES',

View File

@@ -4,10 +4,11 @@ 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.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)

View File

@@ -34,6 +34,7 @@ class MenuItem:
link: str
link_text: str
permissions: Optional[Sequence[str]] = ()
staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = ()

View File

@@ -360,6 +360,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 +383,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 +401,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 +423,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
),
),
),

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.0'
VERSION = '3.6.2'
# Hostname
HOSTNAME = platform.node()
@@ -496,6 +496,7 @@ 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
)
SERIALIZATION_MODULES = {

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
$btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>");
// Code
$code-color: $gray-200;
$code-color: $gray-600;
$kbd-color: $white;
$kbd-bg: $gray-300;
$pre-color: null;

View File

@@ -4,14 +4,18 @@
{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
{% block message %}
<p>{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}</p>
<p>
{% blocktrans trimmed %}
Swap these terminations for circuit {{ circuit }}?
{% endblocktrans %}
</p>
<ul>
<li>
<strong>{% trans "A side" %}:</strong>
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
{{ ''|placeholder }}
{% trans "None" %}
{% endif %}
</li>
<li>
@@ -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 %}
</li>
</ul>

View File

@@ -6,7 +6,7 @@
{% block message %}
<p>
{% 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 %}
</p>

View File

@@ -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 %}
@@ -51,10 +51,10 @@
<th scope="row">{% trans "Total length" %}</th>
<td>
{% 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 %}
<span class="text-muted">{% trans "N/A" %}</span>
{{ ''|placeholder }}
{% endif %}
</td>
</tr>

View File

@@ -0,0 +1,22 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% block bulk_buttons %}
<div class="btn-group" role="group">
{% 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" %}
<button type="submit" name="_rename" formaction="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
</button>
{% endwith %}
{% endif %}
</div>
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}

View File

@@ -296,7 +296,7 @@
{% for leg in utilization.legs %}
<tr>
<td style="padding-left: 20px">
{% trans "Leg" context "Leg of a power feed" %} {{ leg }}
{% trans "Leg" context "Leg of a power feed" %} {{ leg.name }}
</td>
<td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</td>

View File

@@ -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" %}

View File

@@ -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 %}
<p>{% blocktrans %}Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?{% endblocktrans %}</p>
<p>
{% blocktrans trimmed %}
Are you sure you want to delete this device bay from <strong>{{ devicebay.device }}</strong>?
{% endblocktrans %}
</p>
{% endblock %}

View File

@@ -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 %}
<p>
{% blocktrans with device=device_bay.installed_device %}
{% blocktrans trimmed with device=device_bay.installed_device %}
Are you sure you want to remove <strong>{{ device }}</strong> from <strong>{{ device_bay }}</strong>?
{% endblocktrans %}
</p>

View File

@@ -27,7 +27,7 @@
<div class="float-end">
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% blocktrans %}Add {{ title }}{% endblocktrans %}
{% trans "Add" %} {{ title }}
</a>
</div>
<div class="clearfix"></div>

View File

@@ -27,7 +27,7 @@
<div class="float-end">
<a href="{% url table.Meta.model|viewname:"add" %}?module_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
{% blocktrans %}Add {{ title }}{% endblocktrans %}
{% trans "Add" %} {{ title }}
</a>
</div>
<div class="clearfix"></div>

View File

@@ -44,17 +44,6 @@
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">
{% trans "NAPALM Driver" %}
<i
class="mdi mdi-alert-box text-warning"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{% trans "This field has been deprecated, and will be removed in NetBox v3.6" %}."
></i>
</th>
</tr>
</table>
</div>
</div>

View File

@@ -73,7 +73,7 @@
{% endif %}
</td>
{% else %}
<td class="text-muted">{% trans "N/A" %}</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
{% endwith %}
</tr>

View File

@@ -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 }}

View File

@@ -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 %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit">

View File

@@ -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 %}

View File

@@ -6,7 +6,7 @@
{% block message %}
<p>
{% blocktrans with name=device.virtual_chassis %}
{% blocktrans trimmed with name=device.virtual_chassis %}
Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ name }}?
{% endblocktrans %}
</p>

View File

@@ -7,19 +7,20 @@
</p>
<p>
<i class="mdi mdi-alert"></i>
{% blocktrans %}
<strong>Missing required packages.</strong> This installation of NetBox might be missing one or more required
Python packages. These packages are listed in <code>requirements.txt</code> and
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
<strong>{% trans "Missing required packages" %}.</strong>
{% blocktrans trimmed %}
This installation of NetBox might be missing one or more required Python packages. These packages are listed in
<code>requirements.txt</code> and <code>local_requirements.txt</code>, and are normally installed as part of the
installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the console and
compare the output to the list of required packages.
{% endblocktrans %}
</p>
<p>
<i class="mdi mdi-alert"></i>
{% blocktrans %}
<strong>WSGI service not restarted after upgrade.</strong> 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.
<strong>{% trans "WSGI service not restarted after upgrade" %}.</strong>
{% 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 %}
</p>
{% endblock message %}

View File

@@ -7,10 +7,10 @@
</p>
<p>
<i class="mdi mdi-alert"></i>
{% blocktrans with media_root=settings.MEDIA_ROOT %}
<strong>Insufficient write permission to the media root.</strong> The configured media root is
<code>{{ media_root }}</code>. Ensure that the user NetBox runs as has access to write files to all locations
within this path.
<strong>{% trans "Insufficient write permission to the media root" %}.</strong>
{% blocktrans trimmed with media_root=settings.MEDIA_ROOT %}
The configured media root is <code>{{ media_root }}</code>. Ensure that the user NetBox runs as has access to
write files to all locations within this path.
{% endblocktrans %}
</p>
{% endblock message %}

View File

@@ -7,18 +7,18 @@
</p>
<p>
<i class="mdi mdi-alert"></i>
{% blocktrans %}
<strong>Database migrations missing.</strong> 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
<code>python3 manage.py migrate</code> from the command line.
<strong>{% trans "Database migrations missing" %}.</strong>
{% 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 <code>python3 manage.py migrate</code> from the command line.
{% endblocktrans %}
</p>
<p>
<i class="mdi mdi-alert"></i>
{% blocktrans %}
<strong>Unsupported PostgreSQL version.</strong> 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
<code>SELECT VERSION()</code>.
<strong>{% trans "Unsupported PostgreSQL version" %}.</strong>
{% 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 <code>SELECT VERSION()</code>.
{% endblocktrans %}
</p>
{% endblock message %}

View File

@@ -14,11 +14,11 @@
<div class="controls">
<div class="control-group">
{% 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 %}
</div>
@@ -28,6 +28,14 @@
</div>
{% endblock controls %}
{% block subtitle %}
{% if object.created %}
<div class="object-subtitle">
<span>{% trans "Created" %} {{ object.created|annotated_date }}</span>
</div>
{% endif %}
{% endblock subtitle %}
{% block content %}
<div class="row">
<div class="col col-md-12">

View File

@@ -32,7 +32,7 @@
<td>{{ object.group_name|placeholder }}</td>
</tr>
<tr>
<th scope="row"></th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|markdown|placeholder }}</td>
</tr>
<tr>

View File

@@ -4,6 +4,14 @@
{% block title %}{% trans "Reset Dashboard" %}?{% endblock %}
{% block message %}
<p>{% blocktrans %}This will remove <strong>all</strong> configured widgets and restore the default dashboard configuration.{% endblocktrans %}</p>
<p>{% blocktrans %}This change affects only <i>your</i> dashboard, and will not impact other users.{% endblocktrans %}</p>
<p>
{% blocktrans trimmed %}
This will remove <strong>all</strong> configured widgets and restore the default dashboard configuration.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
This change affects only <i>your</i> dashboard, and will not impact other users.
{% endblocktrans %}
</p>
{% endblock %}

View File

@@ -9,14 +9,16 @@
gs-id="{{ widget.id }}"
>
<div class="card grid-stack-item-content">
<div class="card-header text-center text-light bg-{% if widget.color %}{{ widget.color }}{% else %}secondary{% endif %} p-1">
<div class="card-header text-center text-{{ widget.fg_color }} bg-{{ widget.color|default:"secondary" }} p-1">
<div class="float-start ps-1">
<a href="#"
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
hx-target="#htmx-modal-content"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
><i class="mdi mdi-cog text-gray"></i></a>
>
<i class="mdi mdi-cog text-{{ widget.fg_color }}"></i>
</a>
</div>
<div class="float-end pe-1">
<a href="#"
@@ -24,7 +26,9 @@
hx-target="#htmx-modal-content"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
><i class="mdi mdi-close text-gray"></i></a>
>
<i class="mdi mdi-close text-{{ widget.fg_color }}"></i>
</a>
</div>
{% if widget.title %}
<strong>{{ widget.title }}</strong>

View File

@@ -11,6 +11,6 @@
{% else %}
<p class="text-center text-muted">
<i class="mdi mdi-information-outline"></i>
{% blocktrans %}No bookmarks have been added yet.{% endblocktrans %}
{% trans "No bookmarks have been added yet." %}
</p>
{% endif %}

View File

@@ -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 %}
<div class="float-end">
<a href="{% url 'extras:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">{% blocktrans with count=related_changes_count|add:"1" %}See All {{ count }} Changes{% endblocktrans %}</a>
<a href="{% url 'extras:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
{% blocktrans trimmed with count=related_changes_count|add:"1" %}
See All {{ count }} Changes
{% endblocktrans %}
</a>
</div>
{% endif %}
</div>

View File

@@ -117,7 +117,7 @@
<h4 class="alert-heading">{% trans "No Reports Found" %}</h4>
{% if perms.extras.add_reportmodule %}
{% url 'extras:reportmodule_add' as create_report_url %}
{% blocktrans %}
{% blocktrans trimmed %}
Get started by <a href="{{ create_report_url }}">creating a report</a> from an uploaded file or data source.
{% endblocktrans %}
{% endif %}

View File

@@ -41,8 +41,8 @@
{% if not module.scripts %}
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="mdi mdi-alert"></i>
{% blocktrans with file_path=module.full_path %}
Script file at <code>{{ file_path }}</code> could not be loaded.
{% blocktrans trimmed with file_path=module.full_path %}
Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
{% endblocktrans %}
</div>
{% else %}
@@ -91,7 +91,7 @@
<h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
{% if perms.extras.add_scriptmodule %}
{% url 'extras:scriptmodule_add' as create_script_url %}
{% blocktrans %}
{% blocktrans trimmed %}
Get started by <a href="{{ create_script_url }}">creating a script</a> from an uploaded file or data source.
{% endblocktrans %}
{% endif %}

View File

@@ -24,7 +24,7 @@ Context:
<h4 class="alert-heading">{% trans "Confirm Bulk Deletion" %}</h4>
<hr />
<strong>{% trans "Warning" context "Noun" %}:</strong>
{% blocktrans with count=table.rows|length type_plural=model|meta:"verbose_name_plural" %}
{% blocktrans trimmed with count=table.rows|length type_plural=model|meta:"verbose_name_plural" %}
The following operation will delete <strong>{{ count }}</strong> {{ type_plural }}. Please
carefully review the objects to be deleted and confirm below.
{% endblocktrans %}

View File

@@ -45,6 +45,7 @@ Context:
<input type="hidden" name="import_method" value="direct" />
{% render_field form.data %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group">
<div class="col col-md-12 text-end">
<button type="submit" name="data_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@@ -177,7 +178,7 @@ Context:
{% if field|widget_type == 'dateinput' %}
<small class="text-muted">{% trans "Format: YYYY-MM-DD" %}</small>
{% elif field|widget_type == 'checkboxinput' %}
<small class="text-muted">{% trans "Specify \"true\" or \"false" %}"</small>
<small class="text-muted">{% trans "Specify true or false" %}</small>
{% endif %}
</td>
</tr>
@@ -189,11 +190,15 @@ Context:
</div>
<p class="small text-muted">
<i class="mdi mdi-check-bold text-success"></i>
{% blocktrans %}Required fields <strong>must</strong> be specified for all objects.{% endblocktrans %}
{% blocktrans trimmed %}
Required fields <strong>must</strong> be specified for all objects.
{% endblocktrans %}
</p>
<p class="small text-muted">
<i class="mdi mdi-information-outline"></i>
{% blocktrans with example="vrf.rd" %}Related objects may be referenced by any unique attribute. For example, <code>{{ example }}</code> 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, <code>{{ example }}</code> would identify a VRF by its route distinguisher.
{% endblocktrans %}
</p>
{% endif %}

View File

@@ -12,13 +12,13 @@
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">{% trans "Confirm Bulk Removal" %}</h4>
<p>
{% blocktrans with count=table.rows|length %}
{% blocktrans trimmed with count=table.rows|length %}
<strong>Warning:</strong> The following operation will remove {{ count }} {{ obj_type_plural }} from {{ parent_obj }}.
{% endblocktrans %}
</p>
<hr />
<p class="mb-0">
{% blocktrans %}
{% blocktrans trimmed %}
Please carefully review the {{ obj_type_plural }} to be removed and confirm below.
{% endblocktrans %}
</p>
@@ -35,7 +35,7 @@
{% endfor %}
<div class="text-center">
<button type="submit" name="_confirm" class="btn btn-danger">
{% blocktrans with count=table.rows|length %}
{% blocktrans trimmed with count=table.rows|length %}
Delete these {{ count }} {{ obj_type_plural }}
{% endblocktrans %}
</button>

View File

@@ -3,7 +3,9 @@
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal=table_config %}
{% block table_controls %}
{% include 'inc/table_controls_htmx.html' with table_modal=table_config %}
{% endblock table_controls %}
<form method="post">
{% csrf_token %}
<div class="card">

View File

@@ -16,7 +16,7 @@ Context:
{% if object.pk %}
{% trans "Editing" %} {{ object|meta:"verbose_name" }} {{ object }}
{% else %}
{% blocktrans with object_type=object|meta:"verbose_name" %}
{% blocktrans trimmed with object_type=object|meta:"verbose_name" %}
Add a new {{ object_type }}
{% endblocktrans %}
{% endif %}

View File

@@ -91,7 +91,7 @@ Context:
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
{% blocktrans with count=table.rows|length object_type_plural=table.data.verbose_name_plural %}
{% blocktrans trimmed with count=table.rows|length object_type_plural=table.data.verbose_name_plural %}
Select <strong>all {{ count }} {{ object_type_plural }}</strong> matching query
{% endblocktrans %}
</label>

View File

@@ -8,7 +8,7 @@
</div>
<div class="modal-body">
<p>
{% blocktrans %}
{% blocktrans trimmed %}
Are you sure you want to <strong class="text-danger">delete</strong> {{ object_type }} <strong>{{ object }}</strong>?
{% endblocktrans %}
</p>

View File

@@ -4,7 +4,7 @@
<div class="alert alert-warning text-end" role="alert">
<div class="float-start">
<i class="mdi mdi-alert"></i>
{% blocktrans with model=model|meta:"verbose_name" prerequisite_model=prerequisite_model|meta:"verbose_name" %}
{% blocktrans trimmed with model=model|meta:"verbose_name" prerequisite_model=prerequisite_model|meta:"verbose_name" %}
Before you can add a {{ model }} you must first create a <strong>{{ prerequisite_model }}</strong>.
{% endblocktrans %}
</div>

View File

@@ -46,7 +46,7 @@
</ul>
</div>
<small class="text-end text-muted">
{% blocktrans with start=page.start_index end=page.end_index total=page.paginator.count %}
{% blocktrans trimmed with start=page.start_index end=page.end_index total=page.paginator.count %}
Showing {{ start }}-{{ end }} of {{ total }}
{% endblocktrans %}
</small>

View File

@@ -66,7 +66,7 @@
</ul>
</div>
<small class="text-end text-muted">
{% blocktrans with start=page.start_index end=page.end_index total=page.paginator.count %}
{% blocktrans trimmed with start=page.start_index end=page.end_index total=page.paginator.count %}
Showing {{ start }}-{{ end }} of {{ total }}
{% endblocktrans %}
</small>

View File

@@ -41,6 +41,7 @@
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vminterface" role="tabpanel" aria-labeled-by="vminterface_tab">
{% render_field form.vminterface %}
</div>
{% render_field form.tags %}
</div>
</div>
</div>

View File

@@ -26,13 +26,13 @@
<p>{% trans "Check the following" %}:</p>
<ul>
<li class="tip">
{% blocktrans %}
{% blocktrans trimmed %}
<code>manage.py collectstatic</code> was run during the most recent upgrade. This installs the most
recent iteration of each static file into the static root path.
{% endblocktrans %}
</li>
<li class="tip">
{% 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 <code>STATIC_ROOT</code>
path. Refer to <a href="{{ docs_url }}">the installation documentation</a> for further guidance.
{% endblocktrans %}
@@ -44,7 +44,7 @@
</ul>
</li>
<li class="tip">
{% blocktrans %}
{% blocktrans trimmed %}
The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP
server.
{% endblocktrans %}
@@ -52,7 +52,7 @@
</ul>
<p>
{% url 'home' as home_url %}
{% blocktrans %}
{% blocktrans trimmed %}
Click <a href="{{ home_url }}">here</a> to attempt loading NetBox again.
{% endblocktrans %}
</p>

View File

@@ -32,7 +32,7 @@
</tr>
<tr>
<th scope="row">{% trans "Active" %}</th>
<td>{% checkmark object.active %}</td>
<td>{% checkmark object.is_active %}</td>
</tr>
<tr>
<th scope="row">{% trans "Staff" %}</th>

View File

@@ -6,7 +6,7 @@
{% render_errors form %}
{% block title %}
{% blocktrans %}
{% blocktrans trimmed %}
Add Device to Cluster {{ cluster }}
{% endblocktrans %}
{% endblock %}

View File

@@ -1,10 +1,11 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from extras.forms.mixins import TagsMixin
from extras.models import Tag
from netbox.forms import NetBoxModelForm
from tenancy.models import *
from utilities.forms import BootstrapMixin
from utilities.forms.mixins import BootstrapMixin
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
__all__ = (
@@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm):
}
class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
group = DynamicModelChoiceField(
label=_('Group'),
queryset=ContactGroup.objects.all(),
@@ -141,11 +142,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

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