diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index ec7d667e6..8664768ee 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.6.1
+ placeholder: v3.6.2
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index dc27ebd26..8e3af527a 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.6.1
+ placeholder: v3.6.2
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index 54b3e727e..6e50e5687 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-
The premiere source of truth powering network automation
+
The premier source of truth powering network automation
diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json
index 9a6e2417a..5e8507798 100644
--- a/contrib/generated_schema.json
+++ b/contrib/generated_schema.json
@@ -342,8 +342,10 @@
"100gbase-x-qsfpdd",
"200gbase-x-qsfp56",
"200gbase-x-qsfpdd",
+ "400gbase-x-qsfp112",
"400gbase-x-qsfpdd",
"400gbase-x-osfp",
+ "400gbase-x-osfp-rhs",
"400gbase-x-cdfp",
"400gbase-x-cfp8",
"800gbase-x-qsfpdd",
diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md
index e76930208..d90e6eafc 100644
--- a/docs/configuration/default-values.md
+++ b/docs/configuration/default-values.md
@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
- 'height': 2,
+ 'height': 3,
'title': 'Organization',
'config': {
'models': [
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
},
{
'widget': 'extras.ObjectCountsWidget',
+ 'width': 4,
+ 'height': 3,
'title': 'IPAM',
'color': 'blue',
'config': {
diff --git a/docs/development/internationalization.md b/docs/development/internationalization.md
index bdc7cbdaa..bebc97470 100644
--- a/docs/development/internationalization.md
+++ b/docs/development/internationalization.md
@@ -97,7 +97,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
-3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement.
+3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
```
@@ -107,7 +107,7 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
{% trans "Circuit List" %}
{# A longer string with a context variable #}
-{% blocktrans with count=object.circuits.count %}
+{% blocktrans trimmed with count=object.circuits.count %}
There are {count} circuits. Would you like to continue?
{% endblocktrans %}
```
diff --git a/docs/index.md b/docs/index.md
index 6a53403d6..05cd79f23 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,6 +1,6 @@
{style="height: 100px; margin-bottom: 3em"}
-# The Premiere Network Source of Truth
+# The Premier Network Source of Truth
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md
index 1cd3e1f0a..6ee1c9901 100644
--- a/docs/installation/6-ldap.md
+++ b/docs/installation/6-ldap.md
@@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
!!! warning
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
+## Authenticating with Active Directory
+
+Integrating Active Directory for authentication can be a bit challenging as it may require handling different login formats. This solution will allow users to log in either using their full User Principal Name (UPN) or their username alone, by filtering the DN according to either the `sAMAccountName` or the `userPrincipalName`. The following configuration options will allow your users to enter their usernames in the format `username` or `username@domain.tld`.
+
+Just as before, the configuration options are defined in the file ldap_config.py. First, modify the `AUTH_LDAP_USER_SEARCH` option to match the following:
+
+```python
+AUTH_LDAP_USER_SEARCH = LDAPSearch(
+ "ou=Users,dc=example,dc=com",
+ ldap.SCOPE_SUBTREE,
+ "(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
+)
+```
+
+In addition, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to `None` as described in the previous sections. Next, modify `AUTH_LDAP_USER_ATTR_MAP` to match the following:
+
+```python
+AUTH_LDAP_USER_ATTR_MAP = {
+ "username": "sAMAccountName",
+ "email": "mail",
+ "first_name": "givenName",
+ "last_name": "sn",
+}
+```
+
+Finally, we need to add one more configuration option, `AUTH_LDAP_USER_QUERY_FIELD`. The following should be added to your LDAP configuration file:
+
+```python
+AUTH_LDAP_USER_QUERY_FIELD = "username"
+```
+
+With these configuration options, your users will be able to log in either with or without the UPN suffix.
+
+### Example Configuration
+
+!!! info
+ This configuration is intended to serve as a template, but may need to be modified in accordance with your environment.
+
+```python
+import ldap
+from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
+
+# Server URI
+AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com:3269"
+
+# The following may be needed if you are binding to Active Directory.
+AUTH_LDAP_CONNECTION_OPTIONS = {
+ ldap.OPT_REFERRALS: 0
+}
+
+# Set the DN and password for the NetBox service account.
+AUTH_LDAP_BIND_DN = "CN=NETBOXSA,OU=Service Accounts,DC=example,DC=com"
+AUTH_LDAP_BIND_PASSWORD = "demo"
+
+# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
+# Note that this is a NetBox-specific setting which sets:
+# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+LDAP_IGNORE_CERT_ERRORS = False
+
+# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
+# Note that this is a NetBox-specific setting which sets:
+# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
+LDAP_CA_CERT_DIR = '/etc/ssl/certs'
+
+# Include this setting if you want to validate the LDAP server certificates against your own CA.
+# Note that this is a NetBox-specific setting which sets:
+# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
+LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
+
+# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
+# username is not in their DN (Active Directory).
+AUTH_LDAP_USER_SEARCH = LDAPSearch(
+ "ou=Users,dc=example,dc=com",
+ ldap.SCOPE_SUBTREE,
+ "(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
+)
+
+# If a user's DN is producible from their username, we don't need to search.
+AUTH_LDAP_USER_DN_TEMPLATE = None
+
+# You can map user attributes to Django attributes as so.
+AUTH_LDAP_USER_ATTR_MAP = {
+ "username": "sAMAccountName",
+ "email": "mail",
+ "first_name": "givenName",
+ "last_name": "sn",
+}
+
+AUTH_LDAP_USER_QUERY_FIELD = "username"
+
+# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
+# hierarchy.
+AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
+ "dc=example,dc=com",
+ ldap.SCOPE_SUBTREE,
+ "(objectClass=group)"
+)
+AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
+
+# Define a group required to login.
+AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
+
+# Mirror LDAP group assignments.
+AUTH_LDAP_MIRROR_GROUPS = True
+
+# Define special user types using groups. Exercise great caution when assigning superuser status.
+AUTH_LDAP_USER_FLAGS_BY_GROUP = {
+ "is_active": "cn=active,ou=groups,dc=example,dc=com",
+ "is_staff": "cn=staff,ou=groups,dc=example,dc=com",
+ "is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
+}
+
+# For more granular permissions, we can map LDAP groups to Django groups.
+AUTH_LDAP_FIND_GROUP_PERMS = True
+
+# Cache groups for one hour to reduce LDAP traffic
+AUTH_LDAP_CACHE_TIMEOUT = 3600
+AUTH_LDAP_ALWAYS_UPDATE_USER = True
+```
+
## Troubleshooting LDAP
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
diff --git a/docs/installation/index.md b/docs/installation/index.md
index da50fa5fa..5affdf247 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -1,5 +1,8 @@
# Installation
+!!! info "NetBox Cloud"
+ The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
+
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md
index e9e958a9f..db19b6c11 100644
--- a/docs/release-notes/version-3.6.md
+++ b/docs/release-notes/version-3.6.md
@@ -1,5 +1,35 @@
# 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
@@ -23,7 +53,7 @@
* [#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 modying the configuration when maintenance mode is enabled
+* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
---
diff --git a/netbox/core/choices.py b/netbox/core/choices.py
index 0067dfed8..b5d9d0d90 100644
--- a/netbox/core/choices.py
+++ b/netbox/core/choices.py
@@ -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'),
)
diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py
index d2dacbbe0..82b3962dd 100644
--- a/netbox/core/data_backends.py
+++ b/netbox/core/data_backends.py
@@ -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,
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 1bcf61b20..e1d4a330a 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
+ TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
+ TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
+ (TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
+ (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index a8e75e3c2..74af0696b 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
)
help_texts = {
'time_zone': mark_safe(
- _('Time zone (available options)')
+ '{} ({})'.format(
+ _('Time zone'), _('available options')
+ )
)
}
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'),
}
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'),
}
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
- help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
+ help_text=mark_safe(
+ _('VDC names separated by commas, encased with double quotes. Example:') + ' vdc1,vdc2,vdc3'
+ )
)
type = CSVChoiceField(
label=_('Type'),
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'),
}
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_device = CSVModelChoiceField(
- label=_('Side a device'),
+ label=_('Side A device'),
queryset=Device.objects.all(),
to_field_name='name',
- help_text=_('Side A device')
+ help_text=_('Device name')
)
side_a_type = CSVContentTypeField(
- label=_('Side a type'),
+ label=_('Side A type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text=_('Side A type')
+ help_text=_('Termination type')
)
side_a_name = forms.CharField(
- label=_('Side a name'),
- help_text=_('Side A component name')
+ label=_('Side A name'),
+ help_text=_('Termination name')
)
# Termination B
side_b_device = CSVModelChoiceField(
- label=_('Side b device'),
+ label=_('Side B device'),
queryset=Device.objects.all(),
to_field_name='name',
- help_text=_('Side B device')
+ help_text=_('Device name')
)
side_b_type = CSVContentTypeField(
- label=_('Side b type'),
+ label=_('Side B type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text=_('Side B type')
+ help_text=_('Termination type')
)
side_b_name = forms.CharField(
- label=_('Side b name'),
- help_text=_('Side B component name')
+ label=_('Side B name'),
+ help_text=_('Termination name')
)
# Cable attributes
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
- 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'),
}
def _clean_side(self, side):
diff --git a/netbox/dcim/migrations/0176_device_component_counters.py b/netbox/dcim/migrations/0176_device_component_counters.py
index a911d7fd7..60857ecb9 100644
--- a/netbox/dcim/migrations/0176_device_component_counters.py
+++ b/netbox/dcim/migrations/0176_device_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
- devices = Device.objects.annotate(
- _console_port_count=Count('consoleports', distinct=True),
- _console_server_port_count=Count('consoleserverports', distinct=True),
- _power_port_count=Count('powerports', distinct=True),
- _power_outlet_count=Count('poweroutlets', distinct=True),
- _interface_count=Count('interfaces', distinct=True),
- _front_port_count=Count('frontports', distinct=True),
- _rear_port_count=Count('rearports', distinct=True),
- _device_bay_count=Count('devicebays', distinct=True),
- _module_bay_count=Count('modulebays', distinct=True),
- _inventory_item_count=Count('inventoryitems', distinct=True),
- )
- for device in devices:
- device.console_port_count = device._console_port_count
- device.console_server_port_count = device._console_server_port_count
- device.power_port_count = device._power_port_count
- device.power_outlet_count = device._power_outlet_count
- device.interface_count = device._interface_count
- device.front_port_count = device._front_port_count
- device.rear_port_count = device._rear_port_count
- device.device_bay_count = device._device_bay_count
- device.module_bay_count = device._module_bay_count
- device.inventory_item_count = device._inventory_item_count
-
- Device.objects.bulk_update(devices, [
- 'console_port_count',
- 'console_server_port_count',
- 'power_port_count',
- 'power_outlet_count',
- 'interface_count',
- 'front_port_count',
- 'rear_port_count',
- 'device_bay_count',
- 'module_bay_count',
- 'inventory_item_count',
- ], batch_size=100)
+ update_counts(Device, 'console_port_count', 'consoleports')
+ update_counts(Device, 'console_server_port_count', 'consoleserverports')
+ update_counts(Device, 'power_port_count', 'powerports')
+ update_counts(Device, 'power_outlet_count', 'poweroutlets')
+ update_counts(Device, 'interface_count', 'interfaces')
+ update_counts(Device, 'front_port_count', 'frontports')
+ update_counts(Device, 'rear_port_count', 'rearports')
+ update_counts(Device, 'device_bay_count', 'devicebays')
+ update_counts(Device, 'module_bay_count', 'modulebays')
+ update_counts(Device, 'inventory_item_count', 'inventoryitems')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0177_devicetype_component_counters.py b/netbox/dcim/migrations/0177_devicetype_component_counters.py
index 66d1460d9..b452ce2d9 100644
--- a/netbox/dcim/migrations/0177_devicetype_component_counters.py
+++ b/netbox/dcim/migrations/0177_devicetype_component_counters.py
@@ -2,47 +2,22 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
- device_types = list(DeviceType.objects.all().annotate(
- _console_port_template_count=Count('consoleporttemplates', distinct=True),
- _console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
- _power_port_template_count=Count('powerporttemplates', distinct=True),
- _power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
- _interface_template_count=Count('interfacetemplates', distinct=True),
- _front_port_template_count=Count('frontporttemplates', distinct=True),
- _rear_port_template_count=Count('rearporttemplates', distinct=True),
- _device_bay_template_count=Count('devicebaytemplates', distinct=True),
- _module_bay_template_count=Count('modulebaytemplates', distinct=True),
- _inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
- ))
- for devicetype in device_types:
- devicetype.console_port_template_count = devicetype._console_port_template_count
- devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
- devicetype.power_port_template_count = devicetype._power_port_template_count
- devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
- devicetype.interface_template_count = devicetype._interface_template_count
- devicetype.front_port_template_count = devicetype._front_port_template_count
- devicetype.rear_port_template_count = devicetype._rear_port_template_count
- devicetype.device_bay_template_count = devicetype._device_bay_template_count
- devicetype.module_bay_template_count = devicetype._module_bay_template_count
- devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
-
- DeviceType.objects.bulk_update(device_types, [
- 'console_port_template_count',
- 'console_server_port_template_count',
- 'power_port_template_count',
- 'power_outlet_template_count',
- 'interface_template_count',
- 'front_port_template_count',
- 'rear_port_template_count',
- 'device_bay_template_count',
- 'module_bay_template_count',
- 'inventory_item_template_count',
- ])
+ update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
+ update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
+ update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
+ update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
+ update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
+ update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
+ update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
+ update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
+ update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
+ update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
index 7d07a4d9d..99b304b66 100644
--- a/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
+++ b/netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
@@ -2,17 +2,13 @@ from django.db import migrations
from django.db.models import Count
import utilities.fields
+from utilities.counters import update_counts
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
- vcs = VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True))
-
- for vc in vcs:
- vc.member_count = vc._member_count
-
- VirtualChassis.objects.bulk_update(vcs, ['member_count'], batch_size=100)
+ update_counts(VirtualChassis, 'member_count', 'members')
class Migration(migrations.Migration):
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index de7ba0eb6..ba9e11d46 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -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
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index f58d2bbca..86b6d85fe 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -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)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index e18f25e4f..639f8aadb 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -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)
})
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 857251caf..9cca724ce 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -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])
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index 95f6d41fe..9be8dc0a3 100644
--- a/netbox/dcim/models/mixins.py
+++ b/netbox/dcim/models/mixins.py
@@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
"""
if self.config_template:
return self.config_template
- if self.role.config_template:
+ if self.role and self.role.config_template:
return self.role.config_template
if self.platform and self.platform.config_template:
return self.platform.config_template
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 83e5eb23a..a852ea5cd 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -174,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
- raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
- self.rack, self.rack.site, self.power_panel, self.power_panel.site
+ raise ValidationError(_(
+ "Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
+ ).format(
+ rack=self.rack,
+ rack_site=self.rack.site,
+ powerpanel=self.power_panel,
+ powerpanel_site=self.power_panel.site
))
# AC voltage cannot be negative
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 68c24ca14..34dbcbf30 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -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):
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index aff4a65b5..a6981451f 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -17,7 +17,7 @@ from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from tenancy.models import Tenant
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -2014,6 +2014,7 @@ class ModuleTestCase(
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
}
@@ -2030,6 +2031,7 @@ class ModuleTestCase(
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
}
@@ -2106,6 +2108,7 @@ class ModuleTestCase(
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
}
diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py
index dcf83bc14..0b185d432 100644
--- a/netbox/extras/dashboard/widgets.py
+++ b/netbox/extras/dashboard/widgets.py
@@ -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 {
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index 466baa241..79023a74d 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -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. 00ff00)')),
+ 'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' 00ff00'),
}
diff --git a/netbox/extras/forms/mixins.py b/netbox/extras/forms/mixins.py
index be45f5211..5366dcc28 100644
--- a/netbox/extras/forms/mixins.py
+++ b/netbox/extras/forms/mixins.py
@@ -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)
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index d4e59c170..83a346420 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -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:'
+ ) + ' choice1,First Choice')
)
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 += ' ' # Line break
- help_text += _('Current value: {value}').format(value=value)
- if is_static:
- help_text += _(' (defined statically)')
- elif value == param.default:
- help_text += _(' (default)')
- self.fields[param.name].help_text = help_text
+
+ # Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
+ # CUSTOM_VALIDATORS, which may reference Python objects.)
+ try:
+ json.dumps(value)
if type(value) in (tuple, list):
- value = ', '.join(value)
- self.fields[param.name].initial = value
- if is_static:
+ self.fields[param.name].initial = ', '.join(value)
+ else:
+ self.fields[param.name].initial = value
+ except TypeError:
+ pass
+
+ # Check whether this parameter is statically configured (e.g. in configuration.py)
+ if hasattr(settings, param.name):
self.fields[param.name].disabled = True
+ self.fields[param.name].help_text = _(
+ 'This parameter has been defined statically and cannot be modified.'
+ )
+ continue
+
+ # Set the field's help text
+ help_text = self.fields[param.name].help_text
+ if help_text:
+ help_text += ' ' # Line break
+ help_text += _('Current value: {value}').format(value=value or '—')
+ if value == param.default:
+ help_text += _(' (default)')
+ self.fields[param.name].help_text = help_text
def save(self, commit=True):
instance = super().save(commit=False)
diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py
index 47e8dcd82..2acfcb725 100644
--- a/netbox/extras/models/configs.py
+++ b/netbox/extras/models/configs.py
@@ -146,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
- {'data': _('JSON data must be in object form. Example: {"foo": 123}')}
+ {'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)
def sync_data(self):
@@ -202,7 +202,7 @@ class ConfigContextModel(models.Model):
# Verify that JSON data is provided as an object
if self.local_context_data and type(self.local_context_data) is not dict:
raise ValidationError(
- {'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
+ {'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
)
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 0c4a0c615..e6f339e5a 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -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):
@@ -498,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:
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index a8153e1bb..7ac6b2035 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
from extras.choices import *
from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@@ -1176,7 +1177,11 @@ class CustomFieldImportTest(TestCase):
)
csv_data = '\n'.join(','.join(row) for row in data)
- response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'})
+ response = self.client.post(reverse('dcim:site_import'), {
+ 'data': csv_data,
+ 'format': ImportFormatChoices.CSV,
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
+ })
self.assertEqual(response.status_code, 302)
self.assertEqual(Site.objects.count(), 3)
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 01ef9a2a6..296ed9f4d 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
-from dcim.models import Site
+from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import *
from extras.models import *
from utilities.testing import ViewTestCases, TestCase
@@ -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}',
}
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
index c466e279f..e965bf7b1 100644
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -215,6 +215,9 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
queryset=VLAN.objects.all(),
required=False,
selector=True,
+ query_params={
+ 'site_id': '$site',
+ },
label=_('VLAN'),
)
role = DynamicModelChoiceField(
@@ -728,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta):
fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
- 'tags',
+ 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 89977704a..00c08b3bc 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -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
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 490cf940b..7cf785521 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -1,7 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import F, Prefetch
from django.db.models.expressions import RawSQL
-from django.db.models.functions import Round
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -11,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
+from utilities.tables import get_table_ordering
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
@@ -606,7 +606,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent):
- if not request.GET.get('q') and not request.GET.get('sort'):
+ if not get_table_ordering(request, self.table):
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
return queryset
@@ -952,7 +952,9 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
)
def prep_table_data(self, request, queryset, parent):
- return add_available_vlans(parent.get_child_vlans(), parent)
+ if not get_table_ordering(request, self.table):
+ return add_available_vlans(parent.get_child_vlans(), parent)
+ return queryset
#
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index c5dac90f7..43d0850f0 100644
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -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)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 75099a029..3977201e9 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
-VERSION = '3.6.1'
+VERSION = '3.6.2'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py
index 73f775bd7..bd07886e8 100644
--- a/netbox/netbox/tests/test_import.py
+++ b/netbox/netbox/tests/test_import.py
@@ -3,7 +3,7 @@ from django.test import override_settings
from dcim.models import *
from users.models import ObjectPermission
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import ModelViewTestCase, create_tags
@@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
def _get_csv_data(self, csv_data):
return '\n'.join(csv_data)
+ def test_invalid_headers(self):
+ """
+ Test that import form validation fails when an unknown CSV header is present.
+ """
+ self.add_permissions('dcim.add_region')
+
+ csv_data = [
+ 'name,slug,INVALIDHEADER',
+ 'Region 1,region-1,abc',
+ 'Region 2,region-2,def',
+ 'Region 3,region-3,ghi',
+ ]
+ data = {
+ 'format': ImportFormatChoices.CSV,
+ 'data': self._get_csv_data(csv_data),
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
+ }
+
+ # Form validation should fail with invalid header present
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertEqual(Region.objects.count(), 0)
+
+ # Correct the CSV header name
+ csv_data[0] = 'name,slug,description'
+ data['data'] = self._get_csv_data(csv_data)
+
+ # Validation should succeed
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+ self.assertEqual(Region.objects.count(), 3)
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_valid_tags(self):
csv_data = (
@@ -30,6 +60,7 @@ class CSVImportTestCase(ModelViewTestCase):
data = {
'format': ImportFormatChoices.CSV,
'data': self._get_csv_data(csv_data),
+ 'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css
index 2aa24b72c..2d7142bc6 100644
Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ
diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss
index 4bbe5cea5..2d04b44e3 100644
--- a/netbox/project-static/styles/theme-dark.scss
+++ b/netbox/project-static/styles/theme-dark.scss
@@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
$btn-close-bg: url("data:image/svg+xml,");
// Code
-$code-color: $gray-200;
+$code-color: $gray-600;
$kbd-color: $white;
$kbd-bg: $gray-300;
$pre-color: null;
diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html
index 7c9094d42..1ddb67bac 100644
--- a/netbox/templates/circuits/circuit_terminations_swap.html
+++ b/netbox/templates/circuits/circuit_terminations_swap.html
@@ -4,14 +4,18 @@
{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
{% block message %}
-
{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}
+
+ {% blocktrans trimmed %}
+ Swap these terminations for circuit {{ circuit }}?
+ {% endblocktrans %}
+
{% trans "A side" %}:
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
- {{ ''|placeholder }}
+ {% trans "None" %}
{% endif %}
- {% 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 %}
- {% blocktrans with device=device_bay.installed_device %}
+ {% blocktrans trimmed with device=device_bay.installed_device %}
Are you sure you want to remove {{ device }} from {{ device_bay }}?
{% endblocktrans %}