瀏覽代碼

Merge pull request #13838 from netbox-community/develop

Release v3.6.2
Jeremy Stretch 2 年之前
父節點
當前提交
952be24365
共有 91 個文件被更改,包括 705 次插入335 次删除
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      README.md
  4. 2 0
      contrib/generated_schema.json
  5. 3 1
      docs/configuration/default-values.md
  6. 2 2
      docs/development/internationalization.md
  7. 1 1
      docs/index.md
  8. 120 0
      docs/installation/6-ldap.md
  9. 3 0
      docs/installation/index.md
  10. 31 1
      docs/release-notes/version-3.6.md
  11. 2 2
      netbox/core/choices.py
  12. 2 2
      netbox/core/data_backends.py
  13. 4 0
      netbox/dcim/choices.py
  14. 22 18
      netbox/dcim/forms/bulk_import.py
  15. 11 36
      netbox/dcim/migrations/0176_device_component_counters.py
  16. 11 36
      netbox/dcim/migrations/0177_devicetype_component_counters.py
  17. 2 6
      netbox/dcim/migrations/0178_virtual_chassis_member_counter.py
  18. 2 2
      netbox/dcim/models/cables.py
  19. 1 1
      netbox/dcim/models/device_component_templates.py
  20. 12 11
      netbox/dcim/models/device_components.py
  21. 3 3
      netbox/dcim/models/devices.py
  22. 1 1
      netbox/dcim/models/mixins.py
  23. 7 2
      netbox/dcim/models/power.py
  24. 3 2
      netbox/dcim/tables/devices.py
  25. 4 1
      netbox/dcim/tests/test_views.py
  26. 17 0
      netbox/extras/dashboard/widgets.py
  27. 1 1
      netbox/extras/forms/bulk_import.py
  28. 17 0
      netbox/extras/forms/mixins.py
  29. 35 20
      netbox/extras/forms/model_forms.py
  30. 2 2
      netbox/extras/models/configs.py
  31. 3 2
      netbox/extras/models/customfields.py
  32. 6 1
      netbox/extras/tests/test_customfields.py
  33. 6 4
      netbox/extras/tests/test_views.py
  34. 4 1
      netbox/ipam/forms/model_forms.py
  35. 4 16
      netbox/ipam/models/ip.py
  36. 5 3
      netbox/ipam/views.py
  37. 4 15
      netbox/netbox/forms/base.py
  38. 1 1
      netbox/netbox/settings.py
  39. 32 1
      netbox/netbox/tests/test_import.py
  40. 0 0
      netbox/project-static/dist/netbox-dark.css
  41. 1 1
      netbox/project-static/styles/theme-dark.scss
  42. 7 3
      netbox/templates/circuits/circuit_terminations_swap.html
  43. 1 1
      netbox/templates/dcim/bulk_disconnect.html
  44. 4 4
      netbox/templates/dcim/cable_trace.html
  45. 1 1
      netbox/templates/dcim/device.html
  46. 4 0
      netbox/templates/dcim/device/interfaces.html
  47. 8 2
      netbox/templates/dcim/devicebay_delete.html
  48. 2 2
      netbox/templates/dcim/devicebay_depopulate.html
  49. 1 1
      netbox/templates/dcim/devicetype/component_templates.html
  50. 1 1
      netbox/templates/dcim/moduletype/component_templates.html
  51. 1 1
      netbox/templates/dcim/powerfeed.html
  52. 1 1
      netbox/templates/dcim/rack/base.html
  53. 5 1
      netbox/templates/dcim/virtualchassis_add_member.html
  54. 1 1
      netbox/templates/dcim/virtualchassis_edit.html
  55. 1 1
      netbox/templates/dcim/virtualchassis_remove_member.html
  56. 10 9
      netbox/templates/exceptions/import_error.html
  57. 4 4
      netbox/templates/exceptions/permission_error.html
  58. 8 8
      netbox/templates/exceptions/programming_error.html
  59. 1 1
      netbox/templates/extras/customfield.html
  60. 10 2
      netbox/templates/extras/dashboard/reset.html
  61. 7 3
      netbox/templates/extras/dashboard/widget.html
  62. 1 1
      netbox/templates/extras/dashboard/widgets/bookmarks.html
  63. 5 1
      netbox/templates/extras/objectchange.html
  64. 1 1
      netbox/templates/extras/report_list.html
  65. 3 3
      netbox/templates/extras/script_list.html
  66. 1 1
      netbox/templates/generic/bulk_delete.html
  67. 8 3
      netbox/templates/generic/bulk_import.html
  68. 3 3
      netbox/templates/generic/bulk_remove.html
  69. 3 1
      netbox/templates/generic/object_children.html
  70. 1 1
      netbox/templates/generic/object_edit.html
  71. 1 1
      netbox/templates/generic/object_list.html
  72. 1 1
      netbox/templates/htmx/delete_form.html
  73. 1 1
      netbox/templates/inc/missing_prerequisites.html
  74. 1 1
      netbox/templates/inc/paginator.html
  75. 1 1
      netbox/templates/inc/paginator_htmx.html
  76. 1 0
      netbox/templates/ipam/l2vpntermination_edit.html
  77. 4 4
      netbox/templates/media_failure.html
  78. 1 1
      netbox/templates/virtualization/cluster_add_devices.html
  79. 3 7
      netbox/tenancy/forms/model_forms.py
  80. 16 0
      netbox/utilities/choices.py
  81. 11 0
      netbox/utilities/constants.py
  82. 22 3
      netbox/utilities/counters.py
  83. 43 8
      netbox/utilities/forms/bulk_import.py
  84. 16 11
      netbox/utilities/forms/forms.py
  85. 2 19
      netbox/utilities/management/commands/calculate_cached_counts.py
  86. 16 0
      netbox/utilities/tables.py
  87. 8 4
      netbox/utilities/testing/views.py
  88. 8 0
      netbox/utilities/tests/test_counters.py
  89. 47 0
      netbox/utilities/tests/test_forms.py
  90. 2 6
      netbox/virtualization/migrations/0035_virtualmachine_interface_count.py
  91. 6 6
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

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

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

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

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 <div align="center">
 <div align="center">
   <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
   <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" />
   <img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
   <p></p>
   <p></p>
 </div>
 </div>

+ 2 - 0
contrib/generated_schema.json

@@ -342,8 +342,10 @@
                         "100gbase-x-qsfpdd",
                         "100gbase-x-qsfpdd",
                         "200gbase-x-qsfp56",
                         "200gbase-x-qsfp56",
                         "200gbase-x-qsfpdd",
                         "200gbase-x-qsfpdd",
+                        "400gbase-x-qsfp112",
                         "400gbase-x-qsfpdd",
                         "400gbase-x-qsfpdd",
                         "400gbase-x-osfp",
                         "400gbase-x-osfp",
+                        "400gbase-x-osfp-rhs",
                         "400gbase-x-cdfp",
                         "400gbase-x-cdfp",
                         "400gbase-x-cfp8",
                         "400gbase-x-cfp8",
                         "800gbase-x-qsfpdd",
                         "800gbase-x-qsfpdd",

+ 3 - 1
docs/configuration/default-values.md

@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
     {
     {
         'widget': 'extras.ObjectCountsWidget',
         'widget': 'extras.ObjectCountsWidget',
         'width': 4,
         'width': 4,
-        'height': 2,
+        'height': 3,
         'title': 'Organization',
         'title': 'Organization',
         'config': {
         'config': {
             'models': [
             'models': [
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
     },
     },
     {
     {
         'widget': 'extras.ObjectCountsWidget',
         'widget': 'extras.ObjectCountsWidget',
+        'width': 4,
+        'height': 3,
         'title': 'IPAM',
         'title': 'IPAM',
         'color': 'blue',
         'color': 'blue',
         'config': {
         'config': {

+ 2 - 2
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.
 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.
 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.
 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>
 <h5 class="card-header">{% trans "Circuit List" %}</h5>
 
 
 {# A longer string with a context variable #}
 {# 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?
   There are {count} circuits. Would you like to continue?
 {% endblocktrans %}
 {% endblocktrans %}
 ```
 ```

+ 1 - 1
docs/index.md

@@ -1,6 +1,6 @@
 ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
 ![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.
 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.
 
 

+ 120 - 0
docs/installation/6-ldap.md

@@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
 !!! warning
 !!! warning
     Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
     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
 ## 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`.
 `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`.

+ 3 - 0
docs/installation/index.md

@@ -1,5 +1,8 @@
 # Installation
 # 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.
 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>
 <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>

+ 31 - 1
docs/release-notes/version-3.6.md

@@ -1,5 +1,35 @@
 # NetBox v3.6
 # 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)
 ## v3.6.1 (2023-09-06)
 
 
 ### Enhancements
 ### Enhancements
@@ -23,7 +53,7 @@
 * [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
 * [#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
 * [#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
 * [#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
 
 
 ---
 ---
 
 

+ 2 - 2
netbox/core/choices.py

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

+ 2 - 2
netbox/core/data_backends.py

@@ -81,13 +81,13 @@ class GitBackend(DataBackend):
             required=False,
             required=False,
             label=_('Username'),
             label=_('Username'),
             widget=forms.TextInput(attrs={'class': 'form-control'}),
             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(
         'password': forms.CharField(
             required=False,
             required=False,
             label=_('Password'),
             label=_('Password'),
             widget=forms.TextInput(attrs={'class': 'form-control'}),
             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(
         'branch': forms.CharField(
             required=False,
             required=False,

+ 4 - 0
netbox/dcim/choices.py

@@ -837,8 +837,10 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
     TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
     TYPE_400GE_CFP2 = '400gbase-x-cfp2'
     TYPE_400GE_CFP2 = '400gbase-x-cfp2'
+    TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
+    TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
     TYPE_400GE_CDFP = '400gbase-x-cdfp'
     TYPE_400GE_CDFP = '400gbase-x-cdfp'
     TYPE_400GE_CFP8 = '400gbase-x-cfp8'
     TYPE_400GE_CFP8 = '400gbase-x-cfp8'
     TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
     TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
@@ -989,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
                 (TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
                 (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
                 (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
+                (TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
                 (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
                 (TYPE_400GE_OSFP, 'OSFP (400GE)'),
                 (TYPE_400GE_OSFP, 'OSFP (400GE)'),
+                (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
                 (TYPE_400GE_CDFP, 'CDFP (400GE)'),
                 (TYPE_400GE_CDFP, 'CDFP (400GE)'),
                 (TYPE_400GE_CFP8, 'CPF8 (400GE)'),
                 (TYPE_400GE_CFP8, 'CPF8 (400GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),

+ 22 - 18
netbox/dcim/forms/bulk_import.py

@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
         )
         )
         help_texts = {
         help_texts = {
             'time_zone': mark_safe(
             '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
         model = RackRole
         fields = ('name', 'slug', 'color', 'description', 'tags')
         fields = ('name', 'slug', 'color', 'description', 'tags')
         help_texts = {
         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
         model = DeviceRole
         fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
         fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
         help_texts = {
         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(),
         queryset=VirtualDeviceContext.objects.all(),
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
-        help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
+        help_text=mark_safe(
+            _('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
+        )
     )
     )
     type = CSVChoiceField(
     type = CSVChoiceField(
         label=_('Type'),
         label=_('Type'),
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
         model = InventoryItemRole
         model = InventoryItemRole
         fields = ('name', 'slug', 'color', 'description')
         fields = ('name', 'slug', 'color', 'description')
         help_texts = {
         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):
 class CableImportForm(NetBoxModelImportForm):
     # Termination A
     # Termination A
     side_a_device = CSVModelChoiceField(
     side_a_device = CSVModelChoiceField(
-        label=_('Side a device'),
+        label=_('Side A device'),
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Side A device')
+        help_text=_('Device name')
     )
     )
     side_a_type = CSVContentTypeField(
     side_a_type = CSVContentTypeField(
-        label=_('Side a type'),
+        label=_('Side A type'),
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=CABLE_TERMINATION_MODELS,
         limit_choices_to=CABLE_TERMINATION_MODELS,
-        help_text=_('Side A type')
+        help_text=_('Termination type')
     )
     )
     side_a_name = forms.CharField(
     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
     # Termination B
     side_b_device = CSVModelChoiceField(
     side_b_device = CSVModelChoiceField(
-        label=_('Side b device'),
+        label=_('Side B device'),
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Side B device')
+        help_text=_('Device name')
     )
     )
     side_b_type = CSVContentTypeField(
     side_b_type = CSVContentTypeField(
-        label=_('Side b type'),
+        label=_('Side B type'),
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
         limit_choices_to=CABLE_TERMINATION_MODELS,
         limit_choices_to=CABLE_TERMINATION_MODELS,
-        help_text=_('Side B type')
+        help_text=_('Termination type')
     )
     )
     side_b_name = forms.CharField(
     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
     # Cable attributes
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
             'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
             'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
         ]
         ]
         help_texts = {
         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):
     def _clean_side(self, side):

+ 11 - 36
netbox/dcim/migrations/0176_device_component_counters.py

@@ -2,47 +2,22 @@ from django.db import migrations
 from django.db.models import Count
 from django.db.models import Count
 
 
 import utilities.fields
 import utilities.fields
+from utilities.counters import update_counts
 
 
 
 
 def recalculate_device_counts(apps, schema_editor):
 def recalculate_device_counts(apps, schema_editor):
     Device = apps.get_model("dcim", "Device")
     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):
 class Migration(migrations.Migration):

+ 11 - 36
netbox/dcim/migrations/0177_devicetype_component_counters.py

@@ -2,47 +2,22 @@ from django.db import migrations
 from django.db.models import Count
 from django.db.models import Count
 
 
 import utilities.fields
 import utilities.fields
+from utilities.counters import update_counts
 
 
 
 
 def recalculate_devicetype_template_counts(apps, schema_editor):
 def recalculate_devicetype_template_counts(apps, schema_editor):
     DeviceType = apps.get_model("dcim", "DeviceType")
     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):
 class Migration(migrations.Migration):

+ 2 - 6
netbox/dcim/migrations/0178_virtual_chassis_member_counter.py

@@ -2,17 +2,13 @@ from django.db import migrations
 from django.db.models import Count
 from django.db.models import Count
 
 
 import utilities.fields
 import utilities.fields
+from utilities.counters import update_counts
 
 
 
 
 def populate_virtualchassis_members(apps, schema_editor):
 def populate_virtualchassis_members(apps, schema_editor):
     VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
     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):
 class Migration(migrations.Migration):

+ 2 - 2
netbox/dcim/models/cables.py

@@ -98,10 +98,10 @@ class Cable(PrimaryModel):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # A copy of the PK to be used by __str__ in case the object is deleted
         # 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
         # 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
         self._terminations_modified = False
 
 

+ 1 - 1
netbox/dcim/models/device_component_templates.py

@@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Cache the original DeviceType ID for reference under clean()
         # 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):
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)
         objectchange = super().to_objectchange(action)

+ 12 - 11
netbox/dcim/models/device_components.py

@@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Cache the original Device ID for reference under clean()
         # 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):
     def __str__(self):
         if self.label:
         if self.label:
@@ -799,9 +799,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         if self.bridge and self.bridge.device != self.device:
         if self.bridge and self.bridge.device != self.device:
             if self.device.virtual_chassis is None:
             if self.device.virtual_chassis is None:
                 raise ValidationError({
                 raise ValidationError({
-                    'bridge': _("""
-                    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:
             elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
                 raise ValidationError({
                 raise ValidationError({
@@ -889,10 +889,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
         # Validate untagged VLAN
         # Validate untagged VLAN
         if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
         if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
             raise ValidationError({
             raise ValidationError({
-                'untagged_vlan': _("""
-                    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):
     def save(self, *args, **kwargs):
@@ -1067,9 +1067,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
             frontport_count = self.frontports.count()
             frontport_count = self.frontports.count()
             if self.positions < frontport_count:
             if self.positions < frontport_count:
                 raise ValidationError({
                 raise ValidationError({
-                    "positions": _("""
-                        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)
                 })
                 })
 
 
 
 

+ 3 - 3
netbox/dcim/models/devices.py

@@ -205,11 +205,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Save a copy of u_height for validation in clean()
         # 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
         # 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):
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])

+ 1 - 1
netbox/dcim/models/mixins.py

@@ -69,7 +69,7 @@ class RenderConfigMixin(models.Model):
         """
         """
         if self.config_template:
         if self.config_template:
             return 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
             return self.role.config_template
         if self.platform and self.platform.config_template:
         if self.platform and self.platform.config_template:
             return self.platform.config_template
             return self.platform.config_template

+ 7 - 2
netbox/dcim/models/power.py

@@ -174,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
 
 
         # Rack must belong to same Site as PowerPanel
         # Rack must belong to same Site as PowerPanel
         if self.rack and self.rack.site != self.power_panel.site:
         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
         # AC voltage cannot be negative

+ 3 - 2
netbox/dcim/tables/devices.py

@@ -871,8 +871,9 @@ class ModuleBayTable(DeviceComponentTable):
         url_name='dcim:modulebay_list'
         url_name='dcim:modulebay_list'
     )
     )
     module_status = columns.TemplateColumn(
     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):
     class Meta(DeviceComponentTable.Meta):

+ 4 - 1
netbox/dcim/tests/test_views.py

@@ -17,7 +17,7 @@ from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from tenancy.models import Tenant
 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 utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
@@ -2014,6 +2014,7 @@ class ModuleTestCase(
             'data': {
             'data': {
                 'data': '\n'.join(csv_data),
                 'data': '\n'.join(csv_data),
                 'format': ImportFormatChoices.CSV,
                 'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
         }
         }
 
 
@@ -2030,6 +2031,7 @@ class ModuleTestCase(
             'data': {
             'data': {
                 'data': '\n'.join(csv_data),
                 'data': '\n'.join(csv_data),
                 'format': ImportFormatChoices.CSV,
                 'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
         }
         }
 
 
@@ -2106,6 +2108,7 @@ class ModuleTestCase(
             'data': {
             'data': {
                 'data': '\n'.join(csv_data),
                 'data': '\n'.join(csv_data),
                 'format': ImportFormatChoices.CSV,
                 'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
         }
         }
 
 

+ 17 - 0
netbox/extras/dashboard/widgets.py

@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
 
 
 from extras.choices import BookmarkOrderingChoices
 from extras.choices import BookmarkOrderingChoices
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
+from utilities.choices import ButtonColorChoices
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.templatetags.builtins.filters import render_markdown
 from utilities.templatetags.builtins.filters import render_markdown
@@ -115,6 +116,22 @@ class DashboardWidget:
     def name(self):
     def name(self):
         return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
         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
     @property
     def form_data(self):
     def form_data(self):
         return {
         return {

+ 1 - 1
netbox/extras/forms/bulk_import.py

@@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
         model = Tag
         model = Tag
         fields = ('name', 'slug', 'color', 'description')
         fields = ('name', 'slug', 'color', 'description')
         help_texts = {
         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>'),
         }
         }
 
 
 
 

+ 17 - 0
netbox/extras/forms/mixins.py

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

+ 35 - 20
netbox/extras/forms/model_forms.py

@@ -4,6 +4,7 @@ from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.db.models import Q
 from django.db.models import Q
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from core.forms.mixins import SyncedDataMixin
 from core.forms.mixins import SyncedDataMixin
@@ -75,13 +76,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
             'type': _(
             'type': _(
                 "The type of data stored in this field. For object/multi-object fields, select the related object "
                 "The type of data stored in this field. For object/multi-object fields, select the related object "
                 "type below."
                 "type below."
-            )
+            ),
+            'description': _("This will be displayed as help text for the form field. Markdown is supported.")
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*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:
         if self.instance.pk:
             self.fields['type'].disabled = True
             self.fields['type'].disabled = True
 
 
@@ -90,10 +93,10 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
     extra_choices = forms.CharField(
     extra_choices = forms.CharField(
         widget=ChoicesWidget(),
         widget=ChoicesWidget(),
         required=False,
         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 '
             '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:
     class Meta:
@@ -325,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
         required=False
         required=False
     )
     )
     tenant_groups = DynamicModelMultipleChoiceField(
     tenant_groups = DynamicModelMultipleChoiceField(
-        label=_('Tenat groups'),
+        label=_('Tenant groups'),
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False
         required=False
     )
     )
@@ -515,22 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
         config = get_config()
         config = get_config()
         for param in PARAMS:
         for param in PARAMS:
             value = getattr(config, param.name)
             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):
                 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].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):
     def save(self, commit=True):
         instance = super().save(commit=False)
         instance = super().save(commit=False)

+ 2 - 2
netbox/extras/models/configs.py

@@ -146,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
         # Verify that JSON data is provided as an object
         # Verify that JSON data is provided as an object
         if type(self.data) is not dict:
         if type(self.data) is not dict:
             raise ValidationError(
             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):
     def sync_data(self):
@@ -202,7 +202,7 @@ class ConfigContextModel(models.Model):
         # Verify that JSON data is provided as an object
         # Verify that JSON data is provided as an object
         if self.local_context_data and type(self.local_context_data) is not dict:
         if self.local_context_data and type(self.local_context_data) is not dict:
             raise ValidationError(
             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}'}
             )
             )
 
 
 
 

+ 3 - 2
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.utils import add_blank_choice
 from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
 from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
+from utilities.templatetags.builtins.filters import render_markdown
 from utilities.validators import validate_regex
 from utilities.validators import validate_regex
 
 
 __all__ = (
 __all__ = (
@@ -219,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Cache instance's original name so we can check later whether it has changed
         # 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
     @property
     def search_type(self):
     def search_type(self):
@@ -498,7 +499,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         field.model = self
         field.model = self
         field.label = str(self)
         field.label = str(self)
         if self.description:
         if self.description:
-            field.help_text = escape(self.description)
+            field.help_text = render_markdown(self.description)
 
 
         # Annotate read-only fields
         # Annotate read-only fields
         if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
         if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:

+ 6 - 1
netbox/extras/tests/test_customfields.py

@@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
 from ipam.models import VLAN
 from ipam.models import VLAN
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import APITestCase, TestCase
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
@@ -1176,7 +1177,11 @@ class CustomFieldImportTest(TestCase):
         )
         )
         csv_data = '\n'.join(','.join(row) for row in data)
         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(response.status_code, 302)
         self.assertEqual(Site.objects.count(), 3)
         self.assertEqual(Site.objects.count(), 3)
 
 

+ 6 - 4
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.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 
 
-from dcim.models import Site
+from dcim.models import DeviceType, Manufacturer, Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from utilities.testing import ViewTestCases, TestCase
 from utilities.testing import ViewTestCases, TestCase
@@ -434,7 +434,8 @@ class ConfigContextTestCase(
     @classmethod
     @classmethod
     def setUpTestData(cls):
     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
         # Create three ConfigContexts
         for i in range(1, 4):
         for i in range(1, 4):
@@ -443,7 +444,7 @@ class ConfigContextTestCase(
                 data={'foo': i}
                 data={'foo': i}
             )
             )
             configcontext.save()
             configcontext.save()
-            configcontext.sites.add(site)
+            configcontext.device_types.add(devicetype)
 
 
         cls.form_data = {
         cls.form_data = {
             'name': 'Config Context X',
             'name': 'Config Context X',
@@ -451,11 +452,12 @@ class ConfigContextTestCase(
             'description': 'A new config context',
             'description': 'A new config context',
             'is_active': True,
             'is_active': True,
             'regions': [],
             'regions': [],
-            'sites': [site.pk],
+            'sites': [],
             'roles': [],
             'roles': [],
             'platforms': [],
             'platforms': [],
             'tenant_groups': [],
             'tenant_groups': [],
             'tenants': [],
             'tenants': [],
+            'device_types': [devicetype.id,],
             'tags': [],
             'tags': [],
             'data': '{"foo": 123}',
             'data': '{"foo": 123}',
         }
         }

+ 4 - 1
netbox/ipam/forms/model_forms.py

@@ -215,6 +215,9 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         selector=True,
         selector=True,
+        query_params={
+            'site_id': '$site',
+        },
         label=_('VLAN'),
         label=_('VLAN'),
     )
     )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
@@ -728,7 +731,7 @@ class ServiceCreateForm(ServiceForm):
     class Meta(ServiceForm.Meta):
     class Meta(ServiceForm.Meta):
         fields = [
         fields = [
             'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
             'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
-            'tags',
+            'comments', 'tags',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 4 - 16
netbox/ipam/models/ip.py

@@ -290,8 +290,8 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Cache the original prefix and VRF so we can check if they have changed on post_save
         # 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):
     def __str__(self):
         return str(self.prefix)
         return str(self.prefix)
@@ -554,25 +554,13 @@ class IPRange(PrimaryModel):
             # Check that start & end IP versions match
             # Check that start & end IP versions match
             if self.start_address.version != self.end_address.version:
             if self.start_address.version != self.end_address.version:
                 raise ValidationError({
                 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
             # Check that the start & end IP prefix lengths match
             if self.start_address.prefixlen != self.end_address.prefixlen:
             if self.start_address.prefixlen != self.end_address.prefixlen:
                 raise ValidationError({
                 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
             # Check that the ending address is greater than the starting address

+ 5 - 3
netbox/ipam/views.py

@@ -1,7 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import F, Prefetch
 from django.db.models import F, Prefetch
 from django.db.models.expressions import RawSQL
 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.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -11,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Interface, Site
 from dcim.models import Interface, Site
 from netbox.views import generic
 from netbox.views import generic
 from tenancy.views import ObjectContactsView
 from tenancy.views import ObjectContactsView
+from utilities.tables import get_table_ordering
 from utilities.utils import count_related
 from utilities.utils import count_related
 from utilities.views import ViewTab, register_model_view
 from utilities.views import ViewTab, register_model_view
 from virtualization.filtersets import VMInterfaceFilterSet
 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')
         return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
 
 
     def prep_table_data(self, request, queryset, parent):
     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 add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
         return queryset
         return queryset
 
 
@@ -952,7 +952,9 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
         )
         )
 
 
     def prep_table_data(self, request, queryset, parent):
     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
 
 
 
 
 #
 #

+ 4 - 15
netbox/netbox/forms/base.py

@@ -4,10 +4,11 @@ from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 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 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.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.mixins import BootstrapMixin, CheckLastUpdatedMixin
 
 
 __all__ = (
 __all__ = (
     'NetBoxModelForm',
     '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.
     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.
             the rendered form (optional). If not defined, the all fields will be rendered as a single section.
     """
     """
     fieldsets = ()
     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):
     def _get_content_type(self):
         return ContentType.objects.get_for_model(self._meta.model)
         return ContentType.objects.get_for_model(self._meta.model)

+ 1 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.6.1'
+VERSION = '3.6.2'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 32 - 1
netbox/netbox/tests/test_import.py

@@ -3,7 +3,7 @@ from django.test import override_settings
 
 
 from dcim.models import *
 from dcim.models import *
 from users.models import ObjectPermission
 from users.models import ObjectPermission
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import ModelViewTestCase, create_tags
 from utilities.testing import ModelViewTestCase, create_tags
 
 
 
 
@@ -17,6 +17,36 @@ class CSVImportTestCase(ModelViewTestCase):
     def _get_csv_data(self, csv_data):
     def _get_csv_data(self, csv_data):
         return '\n'.join(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=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_valid_tags(self):
     def test_valid_tags(self):
         csv_data = (
         csv_data = (
@@ -30,6 +60,7 @@ class CSVImportTestCase(ModelViewTestCase):
         data = {
         data = {
             'format': ImportFormatChoices.CSV,
             'format': ImportFormatChoices.CSV,
             'data': self._get_csv_data(csv_data),
             'data': self._get_csv_data(csv_data),
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
         }
         }
 
 
         # Assign model-level permission
         # Assign model-level permission

文件差異過大導致無法顯示
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


+ 1 - 1
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,<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>");
 $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
-$code-color: $gray-200;
+$code-color: $gray-600;
 $kbd-color: $white;
 $kbd-color: $white;
 $kbd-bg: $gray-300;
 $kbd-bg: $gray-300;
 $pre-color: null;
 $pre-color: null;

+ 7 - 3
netbox/templates/circuits/circuit_terminations_swap.html

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

+ 1 - 1
netbox/templates/dcim/bulk_disconnect.html

@@ -6,7 +6,7 @@
 
 
 {% block message %}
 {% block message %}
     <p>
     <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 }}?
         Are you sure you want to disconnect these {{ count }} {{ obj_type_plural }}?
       {% endblocktrans %}
       {% endblocktrans %}
     </p>
     </p>

+ 4 - 4
netbox/templates/dcim/cable_trace.html

@@ -3,7 +3,7 @@
 {% load i18n %}
 {% load i18n %}
 
 
 {% block title %}
 {% 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 }}
     Cable Trace for {{ object_type }} {{ object }}
   {% endblocktrans %}
   {% endblocktrans %}
 {% endblock %}
 {% endblock %}
@@ -51,10 +51,10 @@
                         <th scope="row">{% trans "Total length" %}</th>
                         <th scope="row">{% trans "Total length" %}</th>
                         <td>
                         <td>
                             {% if total_length %}
                             {% if total_length %}
-                            {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
-                            {{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
+                              {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
+                              {{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
                             {% else %}
                             {% else %}
-                            <span class="text-muted">{% trans "N/A" %}</span>
+                              {{ ''|placeholder }}
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                         </tr>
                         </tr>

+ 1 - 1
netbox/templates/dcim/device.html

@@ -296,7 +296,7 @@
                                     {% for leg in utilization.legs %}
                                     {% for leg in utilization.legs %}
                                         <tr>
                                         <tr>
                                             <td style="padding-left: 20px">
                                             <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>
                                             <td>{{ leg.outlet_count }}</td>
                                             <td>{{ leg.outlet_count }}</td>
                                             <td>{{ leg.allocated }}</td>
                                             <td>{{ leg.allocated }}</td>

+ 4 - 0
netbox/templates/dcim/device/interfaces.html

@@ -2,6 +2,10 @@
 {% load helpers %}
 {% load helpers %}
 {% load i18n %}
 {% 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 bulk_delete_controls %}
     {{ block.super }}
     {{ block.super }}
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
     {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}

+ 8 - 2
netbox/templates/dcim/devicebay_delete.html

@@ -2,8 +2,14 @@
 {% load form_helpers %}
 {% load form_helpers %}
 {% load i18n %}
 {% load i18n %}
 
 
-{% block title %}{% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %}{% endblock %}
+{% block title %}
+  {% blocktrans %}Delete device bay {{ devicebay }}?{% endblocktrans %}
+{% endblock %}
 
 
 {% block message %}
 {% 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 %}
 {% endblock %}

+ 2 - 2
netbox/templates/dcim/devicebay_depopulate.html

@@ -3,14 +3,14 @@
 {% load i18n %}
 {% load i18n %}
 
 
 {% block title %}
 {% block title %}
-  {% blocktrans with device=device_bay.installed_device %}
+  {% blocktrans trimmed with device=device_bay.installed_device %}
     Remove {{ device }} from {{ device_bay }}?
     Remove {{ device }} from {{ device_bay }}?
   {% endblocktrans %}
   {% endblocktrans %}
 {% endblock %}
 {% endblock %}
 
 
 {% block message %}
 {% block message %}
   <p>
   <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>?
       Are you sure you want to remove <strong>{{ device }}</strong> from <strong>{{ device_bay }}</strong>?
     {% endblocktrans %}
     {% endblocktrans %}
   </p>
   </p>

+ 1 - 1
netbox/templates/dcim/devicetype/component_templates.html

@@ -27,7 +27,7 @@
                 <div class="float-end">
                 <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">
                     <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>
                         <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                        {% blocktrans %}Add {{ title }}{% endblocktrans %}
+                        {% trans "Add" %} {{ title }}
                     </a>
                     </a>
                 </div>
                 </div>
                 <div class="clearfix"></div>
                 <div class="clearfix"></div>

+ 1 - 1
netbox/templates/dcim/moduletype/component_templates.html

@@ -27,7 +27,7 @@
                 <div class="float-end">
                 <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">
                     <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>
                         <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
-                        {% blocktrans %}Add {{ title }}{% endblocktrans %}
+                        {% trans "Add" %} {{ title }}
                     </a>
                     </a>
                 </div>
                 </div>
                 <div class="clearfix"></div>
                 <div class="clearfix"></div>

+ 1 - 1
netbox/templates/dcim/powerfeed.html

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

+ 1 - 1
netbox/templates/dcim/rack/base.html

@@ -1,7 +1,7 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
 {% load i18n %}
 {% load i18n %}
 
 
-{% block title %}{% blocktrans %}Rack {{ object }}{% endblocktrans %}{% endblock %}
+{% block title %}{% trans "Rack" %} {{ object }}{% endblock %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}

+ 5 - 1
netbox/templates/dcim/virtualchassis_add_member.html

@@ -2,7 +2,11 @@
 {% load form_helpers %}
 {% load form_helpers %}
 {% load i18n %}
 {% 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 %}
 {% block content %}
     <form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
     <form action="" method="post" enctype="multipart/form-data" class="form-object-edit">

+ 1 - 1
netbox/templates/dcim/virtualchassis_edit.html

@@ -4,7 +4,7 @@
 {% load i18n %}
 {% load i18n %}
 
 
 {% block title %}
 {% block title %}
-  {% blocktrans with name=vc_form.instance %}
+  {% blocktrans trimmed with name=vc_form.instance %}
     Editing Virtual Chassis {{ name }}
     Editing Virtual Chassis {{ name }}
   {% endblocktrans %}
   {% endblocktrans %}
 {% endblock %}
 {% endblock %}

+ 1 - 1
netbox/templates/dcim/virtualchassis_remove_member.html

@@ -6,7 +6,7 @@
 
 
 {% block message %}
 {% block message %}
   <p>
   <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 }}?
       Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ name }}?
     {% endblocktrans %}
     {% endblocktrans %}
   </p>
   </p>

+ 10 - 9
netbox/templates/exceptions/import_error.html

@@ -7,19 +7,20 @@
   </p>
   </p>
   <p>
   <p>
     <i class="mdi mdi-alert"></i>
     <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 %}
     {% endblocktrans %}
   </p>
   </p>
   <p>
   <p>
     <i class="mdi mdi-alert"></i>
     <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 %}
     {% endblocktrans %}
   </p>
   </p>
 {% endblock message %}
 {% endblock message %}

+ 4 - 4
netbox/templates/exceptions/permission_error.html

@@ -7,10 +7,10 @@
   </p>
   </p>
   <p>
   <p>
     <i class="mdi mdi-alert"></i>
     <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 %}
     {% endblocktrans %}
   </p>
   </p>
 {% endblock message %}
 {% endblock message %}

+ 8 - 8
netbox/templates/exceptions/programming_error.html

@@ -7,18 +7,18 @@
   </p>
   </p>
   <p>
   <p>
     <i class="mdi mdi-alert"></i>
     <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 %}
     {% endblocktrans %}
   </p>
   </p>
   <p>
   <p>
     <i class="mdi mdi-alert"></i>
     <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 %}
     {% endblocktrans %}
   </p>
   </p>
 {% endblock message %}
 {% endblock message %}

+ 1 - 1
netbox/templates/extras/customfield.html

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

+ 10 - 2
netbox/templates/extras/dashboard/reset.html

@@ -4,6 +4,14 @@
 {% block title %}{% trans "Reset Dashboard" %}?{% endblock %}
 {% block title %}{% trans "Reset Dashboard" %}?{% endblock %}
 
 
 {% block message %}
 {% 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 %}
 {% endblock %}

+ 7 - 3
netbox/templates/extras/dashboard/widget.html

@@ -9,14 +9,16 @@
     gs-id="{{ widget.id }}"
     gs-id="{{ widget.id }}"
 >
 >
   <div class="card grid-stack-item-content">
   <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">
       <div class="float-start ps-1">
         <a href="#"
         <a href="#"
           hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
           hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
           hx-target="#htmx-modal-content"
           hx-target="#htmx-modal-content"
           data-bs-toggle="modal"
           data-bs-toggle="modal"
           data-bs-target="#htmx-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>
       <div class="float-end pe-1">
       <div class="float-end pe-1">
         <a href="#"
         <a href="#"
@@ -24,7 +26,9 @@
           hx-target="#htmx-modal-content"
           hx-target="#htmx-modal-content"
           data-bs-toggle="modal"
           data-bs-toggle="modal"
           data-bs-target="#htmx-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>
       </div>
       {% if widget.title %}
       {% if widget.title %}
         <strong>{{ widget.title }}</strong>
         <strong>{{ widget.title }}</strong>

+ 1 - 1
netbox/templates/extras/dashboard/widgets/bookmarks.html

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

+ 5 - 1
netbox/templates/extras/objectchange.html

@@ -153,7 +153,11 @@
         {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
         {% 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 %}
         {% if related_changes_count > related_changes_table.rows|length %}
             <div class="float-end">
             <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>
             </div>
         {% endif %}
         {% endif %}
     </div>
     </div>

+ 1 - 1
netbox/templates/extras/report_list.html

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

+ 3 - 3
netbox/templates/extras/script_list.html

@@ -41,8 +41,8 @@
           {% if not module.scripts %}
           {% if not module.scripts %}
             <div class="alert alert-warning d-flex align-items-center" role="alert">
             <div class="alert alert-warning d-flex align-items-center" role="alert">
               <i class="mdi mdi-alert"></i>
               <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 %}
               {% endblocktrans %}
             </div>
             </div>
           {% else %}
           {% else %}
@@ -91,7 +91,7 @@
         <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
         <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
         {% if perms.extras.add_scriptmodule %}
         {% if perms.extras.add_scriptmodule %}
           {% url 'extras:scriptmodule_add' as create_script_url %}
           {% 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.
             Get started by <a href="{{ create_script_url }}">creating a script</a> from an uploaded file or data source.
           {% endblocktrans %}
           {% endblocktrans %}
         {% endif %}
         {% endif %}

+ 1 - 1
netbox/templates/generic/bulk_delete.html

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

+ 8 - 3
netbox/templates/generic/bulk_import.html

@@ -45,6 +45,7 @@ Context:
             <input type="hidden" name="import_method" value="direct" />
             <input type="hidden" name="import_method" value="direct" />
             {% render_field form.data %}
             {% render_field form.data %}
             {% render_field form.format %}
             {% render_field form.format %}
+            {% render_field form.csv_delimiter %}
             <div class="form-group">
             <div class="form-group">
               <div class="col col-md-12 text-end">
               <div class="col col-md-12 text-end">
                 <button type="submit" name="data_submit" class="btn btn-primary">{% trans "Submit" %}</button>
                 <button type="submit" name="data_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@@ -177,7 +178,7 @@ Context:
                                     {% if field|widget_type == 'dateinput' %}
                                     {% if field|widget_type == 'dateinput' %}
                                         <small class="text-muted">{% trans "Format: YYYY-MM-DD" %}</small>
                                         <small class="text-muted">{% trans "Format: YYYY-MM-DD" %}</small>
                                     {% elif field|widget_type == 'checkboxinput' %}
                                     {% 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 %}
                                     {% endif %}
                                 </td>
                                 </td>
                             </tr>
                             </tr>
@@ -189,11 +190,15 @@ Context:
     </div>
     </div>
     <p class="small text-muted">
     <p class="small text-muted">
       <i class="mdi mdi-check-bold text-success"></i>
       <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>
     <p class="small text-muted">
     <p class="small text-muted">
       <i class="mdi mdi-information-outline"></i>
       <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>
     </p>
     {% endif %}
     {% endif %}
 
 

+ 3 - 3
netbox/templates/generic/bulk_remove.html

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

+ 3 - 1
netbox/templates/generic/object_children.html

@@ -3,7 +3,9 @@
 {% load i18n %}
 {% load i18n %}
 
 
 {% block content %}
 {% 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">
     <form method="post">
         {% csrf_token %}
         {% csrf_token %}
         <div class="card">
         <div class="card">

+ 1 - 1
netbox/templates/generic/object_edit.html

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

+ 1 - 1
netbox/templates/generic/object_list.html

@@ -91,7 +91,7 @@ Context:
                 <div class="form-check">
                 <div class="form-check">
                   <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
                   <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
                   <label for="select-all" class="form-check-label">
                   <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
                       Select <strong>all {{ count }} {{ object_type_plural }}</strong> matching query
                     {% endblocktrans %}
                     {% endblocktrans %}
                   </label>
                   </label>

+ 1 - 1
netbox/templates/htmx/delete_form.html

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

+ 1 - 1
netbox/templates/inc/missing_prerequisites.html

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

+ 1 - 1
netbox/templates/inc/paginator.html

@@ -46,7 +46,7 @@
         </ul>
         </ul>
       </div>
       </div>
       <small class="text-end text-muted">
       <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 }}
           Showing {{ start }}-{{ end }} of {{ total }}
         {% endblocktrans %}
         {% endblocktrans %}
       </small>
       </small>

+ 1 - 1
netbox/templates/inc/paginator_htmx.html

@@ -66,7 +66,7 @@
         </ul>
         </ul>
       </div>
       </div>
       <small class="text-end text-muted">
       <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 }}
           Showing {{ start }}-{{ end }} of {{ total }}
         {% endblocktrans %}
         {% endblocktrans %}
       </small>
       </small>

+ 1 - 0
netbox/templates/ipam/l2vpntermination_edit.html

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

+ 4 - 4
netbox/templates/media_failure.html

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

+ 1 - 1
netbox/templates/virtualization/cluster_add_devices.html

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

+ 3 - 7
netbox/tenancy/forms/model_forms.py

@@ -1,10 +1,11 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from extras.forms.mixins import TagsMixin
 from extras.models import Tag
 from extras.models import Tag
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from tenancy.models import *
 from tenancy.models import *
-from utilities.forms import BootstrapMixin
+from utilities.forms.mixins import BootstrapMixin
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
 from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
 
 
 __all__ = (
 __all__ = (
@@ -121,7 +122,7 @@ class ContactForm(NetBoxModelForm):
         }
         }
 
 
 
 
-class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
+class ContactAssignmentForm(BootstrapMixin, TagsMixin, forms.ModelForm):
     group = DynamicModelChoiceField(
     group = DynamicModelChoiceField(
         label=_('Group'),
         label=_('Group'),
         queryset=ContactGroup.objects.all(),
         queryset=ContactGroup.objects.all(),
@@ -141,11 +142,6 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
         label=_('Role'),
         label=_('Role'),
         queryset=ContactRole.objects.all()
         queryset=ContactRole.objects.all()
     )
     )
-    tags = DynamicModelMultipleChoiceField(
-        queryset=Tag.objects.all(),
-        required=False,
-        label=_('Tags')
-    )
 
 
     class Meta:
     class Meta:
         model = ContactAssignment
         model = ContactAssignment

+ 16 - 0
netbox/utilities/choices.py

@@ -1,6 +1,8 @@
 from django.conf import settings
 from django.conf import settings
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
+from .constants import CSV_DELIMITERS
+
 
 
 class ChoiceSetMeta(type):
 class ChoiceSetMeta(type):
     """
     """
@@ -230,3 +232,17 @@ class ImportFormatChoices(ChoiceSet):
         (JSON, 'JSON'),
         (JSON, 'JSON'),
         (YAML, 'YAML'),
         (YAML, 'YAML'),
     ]
     ]
+
+
+class CSVDelimiterChoices(ChoiceSet):
+    AUTO = 'auto'
+    COMMA = CSV_DELIMITERS['comma']
+    SEMICOLON = CSV_DELIMITERS['semicolon']
+    TAB = CSV_DELIMITERS['tab']
+
+    CHOICES = [
+        (AUTO, _('Auto-detect')),
+        (COMMA, _('Comma')),
+        (SEMICOLON, _('Semicolon')),
+        (TAB, _('Tab')),
+    ]

+ 11 - 0
netbox/utilities/constants.py

@@ -58,3 +58,14 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'SERVER_NAME',
     'SERVER_NAME',
     'SERVER_PORT',
     'SERVER_PORT',
 ]
 ]
+
+
+#
+# CSV-style format delimiters
+#
+
+CSV_DELIMITERS = {
+    'comma': ',',
+    'semicolon': ';',
+    'tab': '\t',
+}

+ 22 - 3
netbox/utilities/counters.py

@@ -1,5 +1,5 @@
 from django.apps import apps
 from django.apps import apps
-from django.db.models import F
+from django.db.models import F, Count, OuterRef, Subquery
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 
 
 from netbox.registry import registry
 from netbox.registry import registry
@@ -23,6 +23,24 @@ def update_counter(model, pk, counter_name, value):
     )
     )
 
 
 
 
+def update_counts(model, field_name, related_query):
+    """
+    Perform a bulk update for the given model and counter field. For example,
+
+        update_counts(Device, '_interface_count', 'interfaces')
+
+    will effectively set
+
+        Device.objects.update(_interface_count=Count('interfaces'))
+    """
+    subquery = Subquery(
+        model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
+    )
+    return model.objects.update(**{
+        field_name: subquery
+    })
+
+
 #
 #
 # Signal handlers
 # Signal handlers
 #
 #
@@ -34,12 +52,13 @@ def post_save_receiver(sender, instance, created, **kwargs):
     for field_name, counter_name in get_counters_for_model(sender):
     for field_name, counter_name in get_counters_for_model(sender):
         parent_model = sender._meta.get_field(field_name).related_model
         parent_model = sender._meta.get_field(field_name).related_model
         new_pk = getattr(instance, field_name, None)
         new_pk = getattr(instance, field_name, None)
-        old_pk = instance.tracker.get(field_name) if field_name in instance.tracker else None
+        has_old_field = field_name in instance.tracker
+        old_pk = instance.tracker.get(field_name) if has_old_field else None
 
 
         # Update the counters on the old and/or new parents as needed
         # Update the counters on the old and/or new parents as needed
         if old_pk is not None:
         if old_pk is not None:
             update_counter(parent_model, old_pk, counter_name, -1)
             update_counter(parent_model, old_pk, counter_name, -1)
-        if new_pk is not None and (old_pk or created):
+        if new_pk is not None and (has_old_field or created):
             update_counter(parent_model, new_pk, counter_name, 1)
             update_counter(parent_model, new_pk, counter_name, 1)
 
 
 
 

+ 43 - 8
netbox/utilities/forms/bulk_import.py

@@ -7,10 +7,10 @@ from django import forms
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.forms.mixins import SyncedDataMixin
 from core.forms.mixins import SyncedDataMixin
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
+from utilities.constants import CSV_DELIMITERS
 from utilities.forms.utils import parse_csv
 from utilities.forms.utils import parse_csv
 from .mixins import BootstrapMixin
 from .mixins import BootstrapMixin
-from ..choices import ImportMethodChoices
 
 
 
 
 class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
 class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
@@ -24,13 +24,20 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
         help_text=_("Enter object data in CSV, JSON or YAML format.")
         help_text=_("Enter object data in CSV, JSON or YAML format.")
     )
     )
     upload_file = forms.FileField(
     upload_file = forms.FileField(
-        label="Data file",
+        label=_("Data file"),
         required=False
         required=False
     )
     )
     format = forms.ChoiceField(
     format = forms.ChoiceField(
         choices=ImportFormatChoices,
         choices=ImportFormatChoices,
         initial=ImportFormatChoices.AUTO
         initial=ImportFormatChoices.AUTO
     )
     )
+    csv_delimiter = forms.ChoiceField(
+        choices=CSVDelimiterChoices,
+        initial=CSVDelimiterChoices.AUTO,
+        label=_("CSV delimiter"),
+        help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
+        required=False
+    )
 
 
     data_field = 'data'
     data_field = 'data'
 
 
@@ -54,13 +61,18 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
 
 
         # Determine the data format
         # Determine the data format
         if self.cleaned_data['format'] == ImportFormatChoices.AUTO:
         if self.cleaned_data['format'] == ImportFormatChoices.AUTO:
-            format = self._detect_format(data)
+            if self.cleaned_data['csv_delimiter'] != CSVDelimiterChoices.AUTO:
+                # Specifying the CSV delimiter implies CSV format
+                format = ImportFormatChoices.CSV
+            else:
+                format = self._detect_format(data)
         else:
         else:
             format = self.cleaned_data['format']
             format = self.cleaned_data['format']
 
 
         # Process data according to the selected format
         # Process data according to the selected format
         if format == ImportFormatChoices.CSV:
         if format == ImportFormatChoices.CSV:
-            self.cleaned_data['data'] = self._clean_csv(data)
+            delimiter = self.cleaned_data.get('csv_delimiter', CSVDelimiterChoices.AUTO)
+            self.cleaned_data['data'] = self._clean_csv(data, delimiter=delimiter)
         elif format == ImportFormatChoices.JSON:
         elif format == ImportFormatChoices.JSON:
             self.cleaned_data['data'] = self._clean_json(data)
             self.cleaned_data['data'] = self._clean_json(data)
         elif format == ImportFormatChoices.YAML:
         elif format == ImportFormatChoices.YAML:
@@ -78,7 +90,10 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
                 return ImportFormatChoices.JSON
                 return ImportFormatChoices.JSON
             if data.startswith('---') or data.startswith('- '):
             if data.startswith('---') or data.startswith('- '):
                 return ImportFormatChoices.YAML
                 return ImportFormatChoices.YAML
-            if ',' in data.split('\n', 1)[0]:
+            # Look for any of the CSV delimiters in the first line (ignoring the default 'auto' choice)
+            first_line = data.split('\n', 1)[0]
+            csv_delimiters = CSV_DELIMITERS.values()
+            if any(x in first_line for x in csv_delimiters):
                 return ImportFormatChoices.CSV
                 return ImportFormatChoices.CSV
         except IndexError:
         except IndexError:
             pass
             pass
@@ -86,15 +101,35 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
             'format': _('Unable to detect data format. Please specify.')
             'format': _('Unable to detect data format. Please specify.')
         })
         })
 
 
-    def _clean_csv(self, data):
+    def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO):
         """
         """
         Clean CSV-formatted data. The first row will be treated as column headers.
         Clean CSV-formatted data. The first row will be treated as column headers.
         """
         """
+        # Determine the CSV dialect
+        if delimiter == CSVDelimiterChoices.AUTO:
+            # This uses a rough heuristic to detect the CSV dialect based on the presence of supported delimiting
+            # characters. If the data is malformed, we'll fall back to the default Excel dialect.
+            delimiters = ''.join(CSV_DELIMITERS.values())
+            try:
+                dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters)
+            except csv.Error:
+                dialect = csv.excel
+        elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON):
+            dialect = csv.excel
+            dialect.delimiter = delimiter
+        elif delimiter == CSVDelimiterChoices.TAB:
+            dialect = csv.excel_tab
+        else:
+            raise forms.ValidationError({
+                'csv_delimiter': _('Invalid CSV delimiter'),
+            })
+
         stream = StringIO(data.strip())
         stream = StringIO(data.strip())
-        reader = csv.reader(stream)
+        reader = csv.reader(stream, dialect=dialect)
         headers, records = parse_csv(reader)
         headers, records = parse_csv(reader)
 
 
         # Set CSV headers for reference by the model form
         # Set CSV headers for reference by the model form
+        headers.pop('id', None)
         self._csv_headers = headers
         self._csv_headers = headers
 
 
         return records
         return records

+ 16 - 11
netbox/utilities/forms/forms.py

@@ -40,8 +40,11 @@ class BulkRenameForm(BootstrapMixin, forms.Form):
     """
     """
     An extendable form to be used for renaming objects in bulk.
     An extendable form to be used for renaming objects in bulk.
     """
     """
-    find = forms.CharField()
+    find = forms.CharField(
+        strip=False
+    )
     replace = forms.CharField(
     replace = forms.CharField(
+        strip=False,
         required=False
         required=False
     )
     )
     use_regex = forms.BooleanField(
     use_regex = forms.BooleanField(
@@ -67,22 +70,24 @@ class CSVModelForm(forms.ModelForm):
     """
     """
     ModelForm used for the import of objects in CSV format.
     ModelForm used for the import of objects in CSV format.
     """
     """
-    def __init__(self, *args, headers=None, fields=None, **kwargs):
-        headers = headers or {}
-        fields = fields or []
+    def __init__(self, *args, headers=None, **kwargs):
+        self.headers = headers or {}
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Modify the model form to accommodate any customized to_field_name properties
         # Modify the model form to accommodate any customized to_field_name properties
-        for field, to_field in headers.items():
+        for field, to_field in self.headers.items():
             if to_field is not None:
             if to_field is not None:
                 self.fields[field].to_field_name = to_field
                 self.fields[field].to_field_name = to_field
 
 
-        # Omit any fields not specified (e.g. because the form is being used to
-        # updated rather than create objects)
-        if fields:
-            for field in list(self.fields.keys()):
-                if field not in fields:
-                    del self.fields[field]
+    def clean(self):
+        # Flag any invalid CSV headers
+        for header in self.headers:
+            if header not in self.fields:
+                raise forms.ValidationError(
+                    _("Unrecognized header: {name}").format(name=header)
+                )
+
+        return super().clean()
 
 
 
 
 class FilterForm(BootstrapMixin, forms.Form):
 class FilterForm(BootstrapMixin, forms.Form):

+ 2 - 19
netbox/utilities/management/commands/calculate_cached_counts.py

@@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models import Count, OuterRef, Subquery
 
 
 from netbox.registry import registry
 from netbox.registry import registry
+from utilities.counters import update_counts
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
@@ -26,27 +27,9 @@ class Command(BaseCommand):
 
 
         return models
         return models
 
 
-    def update_counts(self, model, field_name, related_query):
-        """
-        Perform a bulk update for the given model and counter field. For example,
-
-            update_counts(Device, '_interface_count', 'interfaces')
-
-        will effectively set
-
-            Device.objects.update(_interface_count=Count('interfaces'))
-        """
-        self.stdout.write(f'Updating {model.__name__} {field_name}...')
-        subquery = Subquery(
-            model.objects.filter(pk=OuterRef('pk')).annotate(_count=Count(related_query)).values('_count')
-        )
-        return model.objects.update(**{
-            field_name: subquery
-        })
-
     def handle(self, *model_names, **options):
     def handle(self, *model_names, **options):
         for model, mappings in self.collect_models().items():
         for model, mappings in self.collect_models().items():
             for field_name, related_query in mappings.items():
             for field_name, related_query in mappings.items():
-                self.update_counts(model, field_name, related_query)
+                update_counts(model, field_name, related_query)
 
 
         self.stdout.write(self.style.SUCCESS('Finished.'))
         self.stdout.write(self.style.SUCCESS('Finished.'))

+ 16 - 0
netbox/utilities/tables.py

@@ -1,8 +1,24 @@
 __all__ = (
 __all__ = (
+    'get_table_ordering',
     'linkify_phone',
     'linkify_phone',
 )
 )
 
 
 
 
+def get_table_ordering(request, table):
+    """
+    Given a request, return the prescribed table ordering, if any. This may be necessary to determine prior to rendering
+    the table itself.
+    """
+    # Check for an explicit ordering
+    if 'sort' in request.GET:
+        return request.GET['sort'] or None
+
+    # Check for a configured preference
+    if request.user.is_authenticated:
+        if preference := request.user.config.get(f'tables.{table.__name__}.ordering'):
+            return preference
+
+
 def linkify_phone(value):
 def linkify_phone(value):
     """
     """
     Render a telephone number as a hyperlink.
     Render a telephone number as a hyperlink.

+ 8 - 4
netbox/utilities/testing/views.py

@@ -11,7 +11,7 @@ from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from netbox.models.features import ChangeLoggingMixin
 from netbox.models.features import ChangeLoggingMixin
 from users.models import ObjectPermission
 from users.models import ObjectPermission
-from utilities.choices import ImportFormatChoices
+from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
 from .base import ModelTestCase
 from .base import ModelTestCase
 from .utils import disable_warnings, post_data
 from .utils import disable_warnings, post_data
 
 
@@ -580,7 +580,8 @@ class ViewTestCases:
         def test_bulk_import_objects_without_permission(self):
         def test_bulk_import_objects_without_permission(self):
             data = {
             data = {
                 'data': self._get_csv_data(),
                 'data': self._get_csv_data(),
-                'format': 'csv',
+                'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
 
 
             # Test GET without permission
             # Test GET without permission
@@ -597,7 +598,8 @@ class ViewTestCases:
             initial_count = self._get_queryset().count()
             initial_count = self._get_queryset().count()
             data = {
             data = {
                 'data': self._get_csv_data(),
                 'data': self._get_csv_data(),
-                'format': 'csv',
+                'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
 
 
             # Assign model-level permission
             # Assign model-level permission
@@ -626,6 +628,7 @@ class ViewTestCases:
             data = {
             data = {
                 'format': ImportFormatChoices.CSV,
                 'format': ImportFormatChoices.CSV,
                 'data': csv_data,
                 'data': csv_data,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
 
 
             # Assign model-level permission
             # Assign model-level permission
@@ -658,7 +661,8 @@ class ViewTestCases:
             initial_count = self._get_queryset().count()
             initial_count = self._get_queryset().count()
             data = {
             data = {
                 'data': self._get_csv_data(),
                 'data': self._get_csv_data(),
-                'format': 'csv',
+                'format': ImportFormatChoices.CSV,
+                'csv_delimiter': CSVDelimiterChoices.AUTO,
             }
             }
 
 
             # Assign constrained permission
             # Assign constrained permission

+ 8 - 0
netbox/utilities/tests/test_counters.py

@@ -36,10 +36,18 @@ class CountersTest(TestCase):
         self.assertEqual(device1.interface_count, 3)
         self.assertEqual(device1.interface_count, 3)
         self.assertEqual(device2.interface_count, 3)
         self.assertEqual(device2.interface_count, 3)
 
 
+        # test saving an existing object - counter should not change
         interface1.save()
         interface1.save()
         device1.refresh_from_db()
         device1.refresh_from_db()
         self.assertEqual(device1.interface_count, 3)
         self.assertEqual(device1.interface_count, 3)
 
 
+        # test save where tracked object FK back pointer is None
+        vc = VirtualChassis.objects.create(name='Virtual Chassis 1')
+        device1.virtual_chassis = vc
+        device1.save()
+        vc.refresh_from_db()
+        self.assertEqual(vc.member_count, 1)
+
     def test_interface_count_deletion(self):
     def test_interface_count_deletion(self):
         """
         """
         When a tracked object (Interface) is deleted the tracking counter should be updated.
         When a tracked object (Interface) is deleted the tracking counter should be updated.

+ 47 - 0
netbox/utilities/tests/test_forms.py

@@ -3,6 +3,7 @@ from django.test import TestCase
 
 
 from utilities.choices import ImportFormatChoices
 from utilities.choices import ImportFormatChoices
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.bulk_import import BulkImportForm
+from utilities.forms.forms import BulkRenameForm
 from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
 from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
 
 
 
 
@@ -331,3 +332,49 @@ class ImportFormTest(TestCase):
             form._detect_format('')
             form._detect_format('')
         with self.assertRaises(forms.ValidationError):
         with self.assertRaises(forms.ValidationError):
             form._detect_format('?')
             form._detect_format('?')
+
+    def test_csv_delimiters(self):
+        form = BulkImportForm()
+
+        data = (
+            "a,b,c\n"
+            "1,2,3\n"
+            "4,5,6\n"
+        )
+        self.assertEqual(form._clean_csv(data, delimiter=','), [
+            {'a': '1', 'b': '2', 'c': '3'},
+            {'a': '4', 'b': '5', 'c': '6'},
+        ])
+
+        data = (
+            "a;b;c\n"
+            "1;2;3\n"
+            "4;5;6\n"
+        )
+        self.assertEqual(form._clean_csv(data, delimiter=';'), [
+            {'a': '1', 'b': '2', 'c': '3'},
+            {'a': '4', 'b': '5', 'c': '6'},
+        ])
+
+        data = (
+            "a\tb\tc\n"
+            "1\t2\t3\n"
+            "4\t5\t6\n"
+        )
+        self.assertEqual(form._clean_csv(data, delimiter='\t'), [
+            {'a': '1', 'b': '2', 'c': '3'},
+            {'a': '4', 'b': '5', 'c': '6'},
+        ])
+
+
+class BulkRenameFormTest(TestCase):
+    def test_no_strip_whitespace(self):
+        # Tests to make sure Bulk Rename Form isn't stripping whitespaces
+        # See: https://github.com/netbox-community/netbox/issues/13791
+        form = BulkRenameForm(data={
+            "find": " hello ",
+            "replace": " world "
+        })
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data["find"], " hello ")
+        self.assertEqual(form.cleaned_data["replace"], " world ")

+ 2 - 6
netbox/virtualization/migrations/0035_virtualmachine_interface_count.py

@@ -2,17 +2,13 @@ from django.db import migrations
 from django.db.models import Count
 from django.db.models import Count
 
 
 import utilities.fields
 import utilities.fields
+from utilities.counters import update_counts
 
 
 
 
 def populate_virtualmachine_counts(apps, schema_editor):
 def populate_virtualmachine_counts(apps, schema_editor):
     VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
     VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
 
 
-    vms = VirtualMachine.objects.annotate(_interface_count=Count('interfaces', distinct=True))
-
-    for vm in vms:
-        vm.interface_count = vm._interface_count
-
-    VirtualMachine.objects.bulk_update(vms, ['interface_count'], batch_size=100)
+    update_counts(VirtualMachine, 'interface_count', 'interfaces')
 
 
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):

+ 6 - 6
requirements.txt

@@ -2,7 +2,7 @@ bleach==6.0.0
 Django==4.2.5
 Django==4.2.5
 django-cors-headers==4.2.0
 django-cors-headers==4.2.0
 django-debug-toolbar==4.2.0
 django-debug-toolbar==4.2.0
-django-filter==23.2
+django-filter==23.3
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-mptt==0.14
 django-pglocks==1.0.4
 django-pglocks==1.0.4
@@ -12,7 +12,7 @@ django-rich==1.7.0
 django-rq==2.8.1
 django-rq==2.8.1
 django-tables2==2.6.0
 django-tables2==2.6.0
 django-taggit==4.0.0
 django-taggit==4.0.0
-django-timezone-field==6.0
+django-timezone-field==6.0.1
 djangorestframework==3.14.0
 djangorestframework==3.14.0
 drf-spectacular==0.26.4
 drf-spectacular==0.26.4
 drf-spectacular-sidecar==2023.9.1
 drf-spectacular-sidecar==2023.9.1
@@ -21,13 +21,13 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.2.7
+mkdocs-material==9.3.2
 mkdocstrings[python-legacy]==0.23.0
 mkdocstrings[python-legacy]==0.23.0
-netaddr==0.8.0
-Pillow==10.0.0
+netaddr==0.9.0
+Pillow==10.0.1
 psycopg[binary,pool]==3.1.10
 psycopg[binary,pool]==3.1.10
 PyYAML==6.0.1
 PyYAML==6.0.1
-sentry-sdk==1.30.0
+sentry-sdk==1.31.0
 social-auth-app-django==5.3.0
 social-auth-app-django==5.3.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3

部分文件因文件數量過多而無法顯示