Просмотр исходного кода

Merge pull request #17658 from netbox-community/develop

Release v4.1.3
Jeremy Stretch 1 год назад
Родитель
Сommit
6ea0c0c3e9

+ 1 - 1
.github/ISSUE_TEMPLATE/01-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: v4.1.2
+      placeholder: v4.1.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -26,7 +26,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: v4.1.2
+      placeholder: v4.1.3
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 5 - 6
README.md

@@ -7,7 +7,11 @@
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
   <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
   <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
-  <p></p>
+  <p>
+    <strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
+    <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
+    <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
+  </p>
 </div>
 </div>
 
 
 NetBox exists to empower network engineers. Since its release in 2016, it has become the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. As a successor to legacy IPAM and DCIM applications, NetBox provides a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network.
 NetBox exists to empower network engineers. Since its release in 2016, it has become the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. As a successor to legacy IPAM and DCIM applications, NetBox provides a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network.
@@ -81,11 +85,6 @@ NetBox automatically logs the creation, modification, and deletion of all manage
 * The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
 * The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
 * Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
 * Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
 
 
-<p align="center">
-  <a href="https://netboxlabs.com/netbox-cloud/"><img src="docs/media/misc/netbox_cloud.png" alt="NetBox Cloud" /></a><br />
-  Looking for a managed solution? Check out <strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> or <strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>!
-</p>
-
 ## Get Involved
 ## Get Involved
 
 
 * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
 * Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!

+ 3 - 0
docs/features/synchronized-data.md

@@ -13,6 +13,9 @@ To enable remote data synchronization, the NetBox administrator first designates
 !!! info
 !!! info
     Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
     Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
 
 
+!!! info
+    If you are configuring Git and have `HTTP_PROXIES` configured to use the SOCKS protocol, you will also need to install the [`python_socks`](https://pypi.org/project/python-socks/) Python library.
+
 Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database.
 Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database.
 
 
 The following NetBox models can be associated with replicated data files:
 The following NetBox models can be associated with replicated data files:

BIN
docs/media/misc/netbox_cloud.png


BIN
docs/media/misc/netbox_logo.png


+ 2 - 2
docs/models/ipam/asn.md

@@ -1,6 +1,6 @@
 # ASNs
 # ASNs
 
 
-An Autonomous System Number (ASN) is a numeric identifier used in the BGP protocol to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating and transiting through. NetBox support both 32- and 64- ASNs.
+An Autonomous System Number (ASN) is a numeric identifier used in the Border Gateway Protocol (BGP) to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating from or transiting through. NetBox supports both 16- and 32-bit ASNs.
 
 
 ASNs must be globally unique within NetBox, and may be allocated from within a [defined range](./asnrange.md). Each ASN may be assigned to multiple [sites](../dcim/site.md).
 ASNs must be globally unique within NetBox, and may be allocated from within a [defined range](./asnrange.md). Each ASN may be assigned to multiple [sites](../dcim/site.md).
 
 
@@ -8,7 +8,7 @@ ASNs must be globally unique within NetBox, and may be allocated from within a [
 
 
 ### AS Number
 ### AS Number
 
 
-The 32- or 64-bit AS number.
+The 16- or 32-bit AS number.
 
 
 ### RIR
 ### RIR
 
 

+ 12 - 0
docs/release-notes/version-4.1.md

@@ -1,5 +1,17 @@
 # NetBox v4.1
 # NetBox v4.1
 
 
+## v4.1.3 (2024-10-02)
+
+### Enhancements
+
+* [#17639](https://github.com/netbox-community/netbox/issues/17639) - Add SOCKS support to proxy settings for Git remote data sources
+
+### Bug Fixes
+
+* [#17558](https://github.com/netbox-community/netbox/issues/17558) - Raise validation error when attempting to remove a custom field choice in use
+
+---
+
 ## v4.1.2 (2024-09-26)
 ## v4.1.2 (2024-09-26)
 
 
 ### Enhancements
 ### Enhancements

+ 17 - 3
netbox/core/data_backends.py

@@ -8,10 +8,13 @@ from urllib.parse import urlparse
 
 
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from netbox.data_backends import DataBackend
 from netbox.data_backends import DataBackend
 from netbox.utils import register_data_backend
 from netbox.utils import register_data_backend
+from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
+from utilities.socks import ProxyPoolManager
 from .exceptions import SyncError
 from .exceptions import SyncError
 
 
 __all__ = (
 __all__ = (
@@ -67,11 +70,18 @@ class GitBackend(DataBackend):
 
 
         # Initialize backend config
         # Initialize backend config
         config = ConfigDict()
         config = ConfigDict()
+        self.use_socks = False
 
 
         # Apply HTTP proxy (if configured)
         # Apply HTTP proxy (if configured)
-        if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
-            if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
-                config.set("http", "proxy", proxy)
+        if settings.HTTP_PROXIES:
+            if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None):
+                if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
+                    raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
+
+                if self.url_scheme in ('http', 'https'):
+                    config.set("http", "proxy", proxy)
+                    if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
+                        self.use_socks = True
 
 
         return config
         return config
 
 
@@ -87,6 +97,10 @@ class GitBackend(DataBackend):
             "errstream": porcelain.NoneStream(),
             "errstream": porcelain.NoneStream(),
         }
         }
 
 
+        # check if using socks for proxy - if so need to use custom pool_manager
+        if self.use_socks:
+            clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme))
+
         if self.url_scheme in ('http', 'https'):
         if self.url_scheme in ('http', 'https'):
             if self.params.get('username'):
             if self.params.get('username'):
                 clone_args.update(
                 clone_args.update(

+ 32 - 0
netbox/extras/models/customfields.py

@@ -785,6 +785,12 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache the initial set of choices for comparison under clean()
+        self._original_extra_choices = self.__dict__.get('extra_choices')
+
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('extras:customfieldchoiceset', args=[self.pk])
         return reverse('extras:customfieldchoiceset', args=[self.pk])
 
 
@@ -818,6 +824,32 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
         if not self.base_choices and not self.extra_choices:
         if not self.base_choices and not self.extra_choices:
             raise ValidationError(_("Must define base or extra choices."))
             raise ValidationError(_("Must define base or extra choices."))
 
 
+        # Check whether any choices have been removed. If so, check whether any of the removed
+        # choices are still set in custom field data for any object.
+        original_choices = set([
+            c[0] for c in self._original_extra_choices
+        ]) if self._original_extra_choices else set()
+        current_choices = set([
+            c[0] for c in self.extra_choices
+        ]) if self.extra_choices else set()
+        if removed_choices := original_choices - current_choices:
+            for custom_field in self.choices_for.all():
+                for object_type in custom_field.object_types.all():
+                    model = object_type.model_class()
+                    for choice in removed_choices:
+                        # Form the query based on the type of custom field
+                        if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+                            query_args = {f"custom_field_data__{custom_field.name}__contains": choice}
+                        else:
+                            query_args = {f"custom_field_data__{custom_field.name}": choice}
+                        # Raise a ValidationError if there are any objects which still reference the removed choice
+                        if model.objects.filter(models.Q(**query_args)).exists():
+                            raise ValidationError(
+                                _(
+                                    "Cannot remove choice {choice} as there are {model} objects which reference it."
+                                ).format(choice=choice, model=object_type)
+                            )
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
         # Sort choices if alphabetical ordering is enforced
         # Sort choices if alphabetical ordering is enforced

+ 68 - 0
netbox/extras/tests/test_customfields.py

@@ -343,6 +343,74 @@ class CustomFieldTest(TestCase):
         instance.refresh_from_db()
         instance.refresh_from_db()
         self.assertIsNone(instance.custom_field_data.get(cf.name))
         self.assertIsNone(instance.custom_field_data.get(cf.name))
 
 
+    def test_remove_selected_choice(self):
+        """
+        Removing a ChoiceSet choice that is referenced by an object should raise
+        a ValidationError exception.
+        """
+        CHOICES = (
+            ('a', 'Option A'),
+            ('b', 'Option B'),
+            ('c', 'Option C'),
+            ('d', 'Option D'),
+        )
+
+        # Create a set of custom field choices
+        choice_set = CustomFieldChoiceSet.objects.create(
+            name='Custom Field Choice Set 1',
+            extra_choices=CHOICES
+        )
+
+        # Create a select custom field
+        cf = CustomField.objects.create(
+            name='select_field',
+            type=CustomFieldTypeChoices.TYPE_SELECT,
+            required=False,
+            choice_set=choice_set
+        )
+        cf.object_types.set([self.object_type])
+
+        # Create a multi-select custom field
+        cf_multiselect = CustomField.objects.create(
+            name='multiselect_field',
+            type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+            required=False,
+            choice_set=choice_set
+        )
+        cf_multiselect.object_types.set([self.object_type])
+
+        # Assign a choice for both custom fields on an object
+        instance = Site.objects.first()
+        instance.custom_field_data[cf.name] = 'a'
+        instance.custom_field_data[cf_multiselect.name] = ['b', 'c']
+        instance.save()
+
+        # Attempting to delete a selected choice should fail
+        with self.assertRaises(ValidationError):
+            choice_set.extra_choices = (
+                ('b', 'Option B'),
+                ('c', 'Option C'),
+                ('d', 'Option D'),
+            )
+            choice_set.full_clean()
+
+        # Attempting to delete either of the multi-select choices should fail
+        with self.assertRaises(ValidationError):
+            choice_set.extra_choices = (
+                ('a', 'Option A'),
+                ('b', 'Option B'),
+                ('d', 'Option D'),
+            )
+            choice_set.full_clean()
+
+        # Removing a non-selected choice should succeed
+        choice_set.extra_choices = (
+            ('a', 'Option A'),
+            ('b', 'Option B'),
+            ('c', 'Option C'),
+        )
+        choice_set.full_clean()
+
     def test_object_field(self):
     def test_object_field(self):
         value = VLAN.objects.create(name='VLAN 1', vid=1).pk
         value = VLAN.objects.create(name='VLAN 1', vid=1).pk
 
 

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.1.2"
+version: "4.1.3"
 edition: "Community"
 edition: "Community"
-published: "2024-09-26"
+published: "2024-10-02"

+ 78 - 71
netbox/translations/en/LC_MESSAGES/django.po

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-09-25 05:02+0000\n"
+"POT-Creation-Date: 2024-10-02 05:01+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -82,8 +82,8 @@ msgstr ""
 
 
 #: netbox/circuits/choices.py:21 netbox/dcim/choices.py:20
 #: netbox/circuits/choices.py:21 netbox/dcim/choices.py:20
 #: netbox/dcim/choices.py:102 netbox/dcim/choices.py:185
 #: netbox/dcim/choices.py:102 netbox/dcim/choices.py:185
-#: netbox/dcim/choices.py:231 netbox/dcim/choices.py:1522
-#: netbox/dcim/choices.py:1598 netbox/dcim/choices.py:1648
+#: netbox/dcim/choices.py:231 netbox/dcim/choices.py:1524
+#: netbox/dcim/choices.py:1600 netbox/dcim/choices.py:1650
 #: netbox/virtualization/choices.py:20 netbox/virtualization/choices.py:45
 #: netbox/virtualization/choices.py:20 netbox/virtualization/choices.py:45
 #: netbox/vpn/choices.py:18
 #: netbox/vpn/choices.py:18
 msgid "Planned"
 msgid "Planned"
@@ -96,7 +96,7 @@ msgstr ""
 #: netbox/circuits/choices.py:23 netbox/core/tables/tasks.py:22
 #: netbox/circuits/choices.py:23 netbox/core/tables/tasks.py:22
 #: netbox/dcim/choices.py:22 netbox/dcim/choices.py:103
 #: netbox/dcim/choices.py:22 netbox/dcim/choices.py:103
 #: netbox/dcim/choices.py:184 netbox/dcim/choices.py:230
 #: netbox/dcim/choices.py:184 netbox/dcim/choices.py:230
-#: netbox/dcim/choices.py:1597 netbox/dcim/choices.py:1647
+#: netbox/dcim/choices.py:1599 netbox/dcim/choices.py:1649
 #: netbox/extras/tables/tables.py:495 netbox/ipam/choices.py:31
 #: netbox/extras/tables/tables.py:495 netbox/ipam/choices.py:31
 #: netbox/ipam/choices.py:49 netbox/ipam/choices.py:69
 #: netbox/ipam/choices.py:49 netbox/ipam/choices.py:69
 #: netbox/ipam/choices.py:154 netbox/templates/extras/configcontext.html:25
 #: netbox/ipam/choices.py:154 netbox/templates/extras/configcontext.html:25
@@ -107,8 +107,8 @@ msgid "Active"
 msgstr ""
 msgstr ""
 
 
 #: netbox/circuits/choices.py:24 netbox/dcim/choices.py:183
 #: netbox/circuits/choices.py:24 netbox/dcim/choices.py:183
-#: netbox/dcim/choices.py:229 netbox/dcim/choices.py:1596
-#: netbox/dcim/choices.py:1649 netbox/virtualization/choices.py:24
+#: netbox/dcim/choices.py:229 netbox/dcim/choices.py:1598
+#: netbox/dcim/choices.py:1651 netbox/virtualization/choices.py:24
 #: netbox/virtualization/choices.py:43
 #: netbox/virtualization/choices.py:43
 msgid "Offline"
 msgid "Offline"
 msgstr ""
 msgstr ""
@@ -121,7 +121,7 @@ msgstr ""
 msgid "Decommissioned"
 msgid "Decommissioned"
 msgstr ""
 msgstr ""
 
 
-#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:1609
+#: netbox/circuits/choices.py:90 netbox/dcim/choices.py:1611
 #: netbox/tenancy/choices.py:17
 #: netbox/tenancy/choices.py:17
 msgid "Primary"
 msgid "Primary"
 msgstr ""
 msgstr ""
@@ -1587,7 +1587,7 @@ msgstr ""
 #: netbox/core/choices.py:22 netbox/core/choices.py:59
 #: netbox/core/choices.py:22 netbox/core/choices.py:59
 #: netbox/core/constants.py:20 netbox/core/tables/tasks.py:34
 #: netbox/core/constants.py:20 netbox/core/tables/tasks.py:34
 #: netbox/dcim/choices.py:187 netbox/dcim/choices.py:233
 #: netbox/dcim/choices.py:187 netbox/dcim/choices.py:233
-#: netbox/dcim/choices.py:1599 netbox/virtualization/choices.py:47
+#: netbox/dcim/choices.py:1601 netbox/virtualization/choices.py:47
 msgid "Failed"
 msgid "Failed"
 msgstr ""
 msgstr ""
 
 
@@ -1653,42 +1653,42 @@ msgstr ""
 msgid "Cancelled"
 msgid "Cancelled"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:29 netbox/core/tables/plugins.py:51
+#: netbox/core/data_backends.py:32 netbox/core/tables/plugins.py:51
 #: netbox/templates/core/plugin.html:87
 #: netbox/templates/core/plugin.html:87
 #: netbox/templates/dcim/interface.html:216
 #: netbox/templates/dcim/interface.html:216
 msgid "Local"
 msgid "Local"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:47 netbox/core/tables/change_logging.py:20
+#: netbox/core/data_backends.py:50 netbox/core/tables/change_logging.py:20
 #: netbox/templates/account/profile.html:15 netbox/templates/users/user.html:17
 #: netbox/templates/account/profile.html:15 netbox/templates/users/user.html:17
 #: netbox/users/tables.py:31
 #: netbox/users/tables.py:31
 msgid "Username"
 msgid "Username"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:49 netbox/core/data_backends.py:55
+#: netbox/core/data_backends.py:52 netbox/core/data_backends.py:58
 msgid "Only used for cloning with HTTP(S)"
 msgid "Only used for cloning with HTTP(S)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:53 netbox/templates/account/base.html:23
+#: netbox/core/data_backends.py:56 netbox/templates/account/base.html:23
 #: netbox/templates/account/password.html:12
 #: netbox/templates/account/password.html:12
 #: netbox/users/forms/model_forms.py:171
 #: netbox/users/forms/model_forms.py:171
 msgid "Password"
 msgid "Password"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:59
+#: netbox/core/data_backends.py:62
 msgid "Branch"
 msgid "Branch"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:106
+#: netbox/core/data_backends.py:120
 #, python-brace-format
 #, python-brace-format
 msgid "Fetching remote data failed ({name}): {error}"
 msgid "Fetching remote data failed ({name}): {error}"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:119
+#: netbox/core/data_backends.py:133
 msgid "AWS access key ID"
 msgid "AWS access key ID"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/data_backends.py:123
+#: netbox/core/data_backends.py:137
 msgid "AWS secret access key"
 msgid "AWS secret access key"
 msgstr ""
 msgstr ""
 
 
@@ -1911,7 +1911,7 @@ msgstr ""
 msgid "Rack Elevations"
 msgid "Rack Elevations"
 msgstr ""
 msgstr ""
 
 
-#: netbox/core/forms/model_forms.py:157 netbox/dcim/choices.py:1510
+#: netbox/core/forms/model_forms.py:157 netbox/dcim/choices.py:1512
 #: netbox/dcim/forms/bulk_edit.py:969 netbox/dcim/forms/bulk_edit.py:1357
 #: netbox/dcim/forms/bulk_edit.py:969 netbox/dcim/forms/bulk_edit.py:1357
 #: netbox/dcim/forms/bulk_edit.py:1375 netbox/dcim/tables/racks.py:158
 #: netbox/dcim/forms/bulk_edit.py:1375 netbox/dcim/tables/racks.py:158
 #: netbox/netbox/navigation/menu.py:291 netbox/netbox/navigation/menu.py:295
 #: netbox/netbox/navigation/menu.py:291 netbox/netbox/navigation/menu.py:295
@@ -2477,7 +2477,7 @@ msgid "Staging"
 msgstr ""
 msgstr ""
 
 
 #: netbox/dcim/choices.py:23 netbox/dcim/choices.py:189
 #: netbox/dcim/choices.py:23 netbox/dcim/choices.py:189
-#: netbox/dcim/choices.py:234 netbox/dcim/choices.py:1523
+#: netbox/dcim/choices.py:234 netbox/dcim/choices.py:1525
 #: netbox/virtualization/choices.py:23 netbox/virtualization/choices.py:48
 #: netbox/virtualization/choices.py:23 netbox/virtualization/choices.py:48
 msgid "Decommissioning"
 msgid "Decommissioning"
 msgstr ""
 msgstr ""
@@ -2541,7 +2541,7 @@ msgstr ""
 msgid "Millimeters"
 msgid "Millimeters"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1545
+#: netbox/dcim/choices.py:115 netbox/dcim/choices.py:1547
 msgid "Inches"
 msgid "Inches"
 msgstr ""
 msgstr ""
 
 
@@ -2630,7 +2630,7 @@ msgid "Side to rear"
 msgstr ""
 msgstr ""
 
 
 #: netbox/dcim/choices.py:209 netbox/dcim/choices.py:253
 #: netbox/dcim/choices.py:209 netbox/dcim/choices.py:253
-#: netbox/dcim/choices.py:1295
+#: netbox/dcim/choices.py:1297
 msgid "Passive"
 msgid "Passive"
 msgstr ""
 msgstr ""
 
 
@@ -2659,8 +2659,8 @@ msgid "Proprietary"
 msgstr ""
 msgstr ""
 
 
 #: netbox/dcim/choices.py:575 netbox/dcim/choices.py:818
 #: netbox/dcim/choices.py:575 netbox/dcim/choices.py:818
-#: netbox/dcim/choices.py:1211 netbox/dcim/choices.py:1213
-#: netbox/dcim/choices.py:1439 netbox/dcim/choices.py:1441
+#: netbox/dcim/choices.py:1213 netbox/dcim/choices.py:1215
+#: netbox/dcim/choices.py:1441 netbox/dcim/choices.py:1443
 #: netbox/netbox/navigation/menu.py:200
 #: netbox/netbox/navigation/menu.py:200
 msgid "Other"
 msgid "Other"
 msgstr ""
 msgstr ""
@@ -2673,11 +2673,11 @@ msgstr ""
 msgid "Physical"
 msgid "Physical"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:849 netbox/dcim/choices.py:1016
+#: netbox/dcim/choices.py:849 netbox/dcim/choices.py:1017
 msgid "Virtual"
 msgid "Virtual"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:850 netbox/dcim/choices.py:1089
+#: netbox/dcim/choices.py:850 netbox/dcim/choices.py:1091
 #: netbox/dcim/forms/bulk_edit.py:1515 netbox/dcim/forms/filtersets.py:1330
 #: netbox/dcim/forms/bulk_edit.py:1515 netbox/dcim/forms/filtersets.py:1330
 #: netbox/dcim/forms/model_forms.py:988 netbox/dcim/forms/model_forms.py:1397
 #: netbox/dcim/forms/model_forms.py:988 netbox/dcim/forms/model_forms.py:1397
 #: netbox/netbox/navigation/menu.py:140 netbox/netbox/navigation/menu.py:144
 #: netbox/netbox/navigation/menu.py:140 netbox/netbox/navigation/menu.py:144
@@ -2685,11 +2685,11 @@ msgstr ""
 msgid "Wireless"
 msgid "Wireless"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1014
+#: netbox/dcim/choices.py:1015
 msgid "Virtual interfaces"
 msgid "Virtual interfaces"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1017 netbox/dcim/forms/bulk_edit.py:1410
+#: netbox/dcim/choices.py:1018 netbox/dcim/forms/bulk_edit.py:1410
 #: netbox/dcim/forms/bulk_import.py:840 netbox/dcim/forms/model_forms.py:974
 #: netbox/dcim/forms/bulk_import.py:840 netbox/dcim/forms/model_forms.py:974
 #: netbox/dcim/tables/devices.py:657 netbox/templates/dcim/interface.html:106
 #: netbox/dcim/tables/devices.py:657 netbox/templates/dcim/interface.html:106
 #: netbox/templates/virtualization/vminterface.html:43
 #: netbox/templates/virtualization/vminterface.html:43
@@ -2699,27 +2699,27 @@ msgstr ""
 msgid "Bridge"
 msgid "Bridge"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1018
+#: netbox/dcim/choices.py:1019
 msgid "Link Aggregation Group (LAG)"
 msgid "Link Aggregation Group (LAG)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1022
+#: netbox/dcim/choices.py:1023
 msgid "Ethernet (fixed)"
 msgid "Ethernet (fixed)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1037
+#: netbox/dcim/choices.py:1038
 msgid "Ethernet (modular)"
 msgid "Ethernet (modular)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1073
+#: netbox/dcim/choices.py:1075
 msgid "Ethernet (backplane)"
 msgid "Ethernet (backplane)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1105
+#: netbox/dcim/choices.py:1107
 msgid "Cellular"
 msgid "Cellular"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1157 netbox/dcim/forms/filtersets.py:383
+#: netbox/dcim/choices.py:1159 netbox/dcim/forms/filtersets.py:383
 #: netbox/dcim/forms/filtersets.py:809 netbox/dcim/forms/filtersets.py:963
 #: netbox/dcim/forms/filtersets.py:809 netbox/dcim/forms/filtersets.py:963
 #: netbox/dcim/forms/filtersets.py:1542
 #: netbox/dcim/forms/filtersets.py:1542
 #: netbox/templates/dcim/inventoryitem.html:52
 #: netbox/templates/dcim/inventoryitem.html:52
@@ -2727,130 +2727,130 @@ msgstr ""
 msgid "Serial"
 msgid "Serial"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1172
+#: netbox/dcim/choices.py:1174
 msgid "Coaxial"
 msgid "Coaxial"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1192
+#: netbox/dcim/choices.py:1194
 msgid "Stacking"
 msgid "Stacking"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1242
+#: netbox/dcim/choices.py:1244
 msgid "Half"
 msgid "Half"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1243
+#: netbox/dcim/choices.py:1245
 msgid "Full"
 msgid "Full"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1244 netbox/netbox/preferences.py:31
+#: netbox/dcim/choices.py:1246 netbox/netbox/preferences.py:31
 #: netbox/wireless/choices.py:480
 #: netbox/wireless/choices.py:480
 msgid "Auto"
 msgid "Auto"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1255
+#: netbox/dcim/choices.py:1257
 msgid "Access"
 msgid "Access"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1256 netbox/ipam/tables/vlans.py:172
+#: netbox/dcim/choices.py:1258 netbox/ipam/tables/vlans.py:172
 #: netbox/ipam/tables/vlans.py:217
 #: netbox/ipam/tables/vlans.py:217
 #: netbox/templates/dcim/inc/interface_vlans_table.html:7
 #: netbox/templates/dcim/inc/interface_vlans_table.html:7
 msgid "Tagged"
 msgid "Tagged"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1257
+#: netbox/dcim/choices.py:1259
 msgid "Tagged (All)"
 msgid "Tagged (All)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1286
+#: netbox/dcim/choices.py:1288
 msgid "IEEE Standard"
 msgid "IEEE Standard"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1297
+#: netbox/dcim/choices.py:1299
 msgid "Passive 24V (2-pair)"
 msgid "Passive 24V (2-pair)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1298
+#: netbox/dcim/choices.py:1300
 msgid "Passive 24V (4-pair)"
 msgid "Passive 24V (4-pair)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1299
+#: netbox/dcim/choices.py:1301
 msgid "Passive 48V (2-pair)"
 msgid "Passive 48V (2-pair)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1300
+#: netbox/dcim/choices.py:1302
 msgid "Passive 48V (4-pair)"
 msgid "Passive 48V (4-pair)"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1370 netbox/dcim/choices.py:1480
+#: netbox/dcim/choices.py:1372 netbox/dcim/choices.py:1482
 msgid "Copper"
 msgid "Copper"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1393
+#: netbox/dcim/choices.py:1395
 msgid "Fiber Optic"
 msgid "Fiber Optic"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1426 netbox/dcim/choices.py:1509
+#: netbox/dcim/choices.py:1428 netbox/dcim/choices.py:1511
 msgid "USB"
 msgid "USB"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1496
+#: netbox/dcim/choices.py:1498
 msgid "Fiber"
 msgid "Fiber"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1521 netbox/dcim/forms/filtersets.py:1227
+#: netbox/dcim/choices.py:1523 netbox/dcim/forms/filtersets.py:1227
 msgid "Connected"
 msgid "Connected"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1540 netbox/wireless/choices.py:497
+#: netbox/dcim/choices.py:1542 netbox/wireless/choices.py:497
 msgid "Kilometers"
 msgid "Kilometers"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1541 netbox/templates/dcim/cable_trace.html:65
+#: netbox/dcim/choices.py:1543 netbox/templates/dcim/cable_trace.html:65
 #: netbox/wireless/choices.py:498
 #: netbox/wireless/choices.py:498
 msgid "Meters"
 msgid "Meters"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1542
+#: netbox/dcim/choices.py:1544
 msgid "Centimeters"
 msgid "Centimeters"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1543 netbox/wireless/choices.py:499
+#: netbox/dcim/choices.py:1545 netbox/wireless/choices.py:499
 msgid "Miles"
 msgid "Miles"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1544 netbox/templates/dcim/cable_trace.html:66
+#: netbox/dcim/choices.py:1546 netbox/templates/dcim/cable_trace.html:66
 #: netbox/wireless/choices.py:500
 #: netbox/wireless/choices.py:500
 msgid "Feet"
 msgid "Feet"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1560 netbox/templates/dcim/device.html:327
+#: netbox/dcim/choices.py:1562 netbox/templates/dcim/device.html:327
 #: netbox/templates/dcim/rack.html:107
 #: netbox/templates/dcim/rack.html:107
 msgid "Kilograms"
 msgid "Kilograms"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1561
+#: netbox/dcim/choices.py:1563
 msgid "Grams"
 msgid "Grams"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1562 netbox/templates/dcim/device.html:328
+#: netbox/dcim/choices.py:1564 netbox/templates/dcim/device.html:328
 #: netbox/templates/dcim/rack.html:108
 #: netbox/templates/dcim/rack.html:108
 msgid "Pounds"
 msgid "Pounds"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1563
+#: netbox/dcim/choices.py:1565
 msgid "Ounces"
 msgid "Ounces"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1610
+#: netbox/dcim/choices.py:1612
 msgid "Redundant"
 msgid "Redundant"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1631
+#: netbox/dcim/choices.py:1633
 msgid "Single phase"
 msgid "Single phase"
 msgstr ""
 msgstr ""
 
 
-#: netbox/dcim/choices.py:1632
+#: netbox/dcim/choices.py:1634
 msgid "Three-phase"
 msgid "Three-phase"
 msgstr ""
 msgstr ""
 
 
@@ -8233,10 +8233,17 @@ msgstr ""
 msgid "custom field choice sets"
 msgid "custom field choice sets"
 msgstr ""
 msgstr ""
 
 
-#: netbox/extras/models/customfields.py:819
+#: netbox/extras/models/customfields.py:825
 msgid "Must define base or extra choices."
 msgid "Must define base or extra choices."
 msgstr ""
 msgstr ""
 
 
+#: netbox/extras/models/customfields.py:849
+#, python-brace-format
+msgid ""
+"Cannot remove choice {choice} as there are {model} objects which reference "
+"it."
+msgstr ""
+
 #: netbox/extras/models/dashboard.py:18
 #: netbox/extras/models/dashboard.py:18
 msgid "layout"
 msgid "layout"
 msgstr ""
 msgstr ""
@@ -13749,11 +13756,6 @@ msgstr ""
 msgid "Disk Space"
 msgid "Disk Space"
 msgstr ""
 msgstr ""
 
 
-#: netbox/templates/virtualization/cluster.html:72
-msgctxt "Abbreviation for gigabyte"
-msgid "GB"
-msgstr ""
-
 #: netbox/templates/virtualization/cluster/base.html:18
 #: netbox/templates/virtualization/cluster/base.html:18
 msgid "Add Virtual Machine"
 msgid "Add Virtual Machine"
 msgstr ""
 msgstr ""
@@ -14493,7 +14495,7 @@ msgid "Invalid value for a multiple choice field: {value}"
 msgstr ""
 msgstr ""
 
 
 #: netbox/utilities/forms/fields/csv.py:57
 #: netbox/utilities/forms/fields/csv.py:57
-#: netbox/utilities/forms/fields/csv.py:74
+#: netbox/utilities/forms/fields/csv.py:78
 #, python-format
 #, python-format
 msgid "Object not found: %(value)s"
 msgid "Object not found: %(value)s"
 msgstr ""
 msgstr ""
@@ -14504,11 +14506,16 @@ msgid ""
 "\"{value}\" is not a unique value for this field; multiple objects were found"
 "\"{value}\" is not a unique value for this field; multiple objects were found"
 msgstr ""
 msgstr ""
 
 
-#: netbox/utilities/forms/fields/csv.py:97
-msgid "Object type must be specified as \"<app>.<model>\""
+#: netbox/utilities/forms/fields/csv.py:69
+#, python-brace-format
+msgid "\"{field_name}\" is an invalid accessor field name."
 msgstr ""
 msgstr ""
 
 
 #: netbox/utilities/forms/fields/csv.py:101
 #: netbox/utilities/forms/fields/csv.py:101
+msgid "Object type must be specified as \"<app>.<model>\""
+msgstr ""
+
+#: netbox/utilities/forms/fields/csv.py:105
 msgid "Invalid object type"
 msgid "Invalid object type"
 msgstr ""
 msgstr ""
 
 

+ 4 - 0
netbox/utilities/constants.py

@@ -93,3 +93,7 @@ HTML_ALLOWED_ATTRIBUTES = {
     "td": {"align"},
     "td": {"align"},
     "th": {"align"},
     "th": {"align"},
 }
 }
+
+HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS = ['socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h']
+HTTP_PROXY_SOCK_RDNS_SCHEMAS = ['socks4h', 'socks4a', 'socks5h', 'socks5a']
+HTTP_PROXY_SUPPORTED_SCHEMAS = ['http', 'https', 'socks4', 'socks4a', 'socks4h', 'socks5', 'socks5a', 'socks5h']

+ 101 - 0
netbox/utilities/socks.py

@@ -0,0 +1,101 @@
+import logging
+
+from urllib.parse import urlparse
+from urllib3 import PoolManager, HTTPConnectionPool, HTTPSConnectionPool
+from urllib3.connection import HTTPConnection, HTTPSConnection
+from .constants import HTTP_PROXY_SOCK_RDNS_SCHEMAS
+
+
+logger = logging.getLogger('netbox.utilities')
+
+
+class ProxyHTTPConnection(HTTPConnection):
+    """
+    A Proxy connection class that uses a SOCK proxy - used to create
+    a urllib3 PoolManager that routes connections via the proxy.
+    This is for an HTTP (not HTTPS) connection
+    """
+    use_rdns = False
+
+    def __init__(self, *args, **kwargs):
+        socks_options = kwargs.pop('_socks_options')
+        self._proxy_url = socks_options['proxy_url']
+        super().__init__(*args, **kwargs)
+
+    def _new_conn(self):
+        try:
+            from python_socks.sync import Proxy
+        except ModuleNotFoundError as e:
+            logger.info("Configuring an HTTP proxy using SOCKS requires the python_socks library. Check that it has been installed.")
+            raise e
+
+        proxy = Proxy.from_url(self._proxy_url, rdns=self.use_rdns)
+        return proxy.connect(
+            dest_host=self.host,
+            dest_port=self.port,
+            timeout=self.timeout
+        )
+
+
+class ProxyHTTPSConnection(ProxyHTTPConnection, HTTPSConnection):
+    """
+    A Proxy connection class for an HTTPS (not HTTP) connection.
+    """
+    pass
+
+
+class RdnsProxyHTTPConnection(ProxyHTTPConnection):
+    """
+    A Proxy connection class for an HTTP remote-dns connection.
+    I.E. socks4a, socks4h, socks5a, socks5h
+    """
+    use_rdns = True
+
+
+class RdnsProxyHTTPSConnection(ProxyHTTPSConnection):
+    """
+    A Proxy connection class for an HTTPS remote-dns connection.
+    I.E. socks4a, socks4h, socks5a, socks5h
+    """
+    use_rdns = True
+
+
+class ProxyHTTPConnectionPool(HTTPConnectionPool):
+    ConnectionCls = ProxyHTTPConnection
+
+
+class ProxyHTTPSConnectionPool(HTTPSConnectionPool):
+    ConnectionCls = ProxyHTTPSConnection
+
+
+class RdnsProxyHTTPConnectionPool(HTTPConnectionPool):
+    ConnectionCls = RdnsProxyHTTPConnection
+
+
+class RdnsProxyHTTPSConnectionPool(HTTPSConnectionPool):
+    ConnectionCls = RdnsProxyHTTPSConnection
+
+
+class ProxyPoolManager(PoolManager):
+    def __init__(self, proxy_url, timeout=5, num_pools=10, headers=None, **connection_pool_kw):
+        # python_socks uses rdns param to denote remote DNS parsing and
+        # doesn't accept the 'h' or 'a' in the proxy URL
+        if use_rdns := urlparse(proxy_url).scheme in HTTP_PROXY_SOCK_RDNS_SCHEMAS:
+            proxy_url = proxy_url.replace('socks5h:', 'socks5:').replace('socks5a:', 'socks5:')
+            proxy_url = proxy_url.replace('socks4h:', 'socks4:').replace('socks4a:', 'socks4:')
+
+        connection_pool_kw['_socks_options'] = {'proxy_url': proxy_url}
+        connection_pool_kw['timeout'] = timeout
+
+        super().__init__(num_pools, headers, **connection_pool_kw)
+
+        if use_rdns:
+            self.pool_classes_by_scheme = {
+                'http': RdnsProxyHTTPConnectionPool,
+                'https': RdnsProxyHTTPSConnectionPool,
+            }
+        else:
+            self.pool_classes_by_scheme = {
+                'http': ProxyHTTPConnectionPool,
+                'https': ProxyHTTPSConnectionPool,
+            }

+ 3 - 3
requirements.txt

@@ -10,7 +10,7 @@ django-prometheus==2.3.1
 django-redis==5.4.0
 django-redis==5.4.0
 django-rich==1.11.0
 django-rich==1.11.0
 django-rq==2.10.2
 django-rq==2.10.2
-django-taggit==6.0.0
+django-taggit==6.1.0
 django-tables2==2.7.0
 django-tables2==2.7.0
 django-timezone-field==7.0
 django-timezone-field==7.0
 djangorestframework==3.15.2
 djangorestframework==3.15.2
@@ -20,12 +20,12 @@ feedparser==6.0.11
 gunicorn==23.0.0
 gunicorn==23.0.0
 Jinja2==3.1.4
 Jinja2==3.1.4
 Markdown==3.7
 Markdown==3.7
-mkdocs-material==9.5.38
+mkdocs-material==9.5.39
 mkdocstrings[python-legacy]==0.26.1
 mkdocstrings[python-legacy]==0.26.1
 netaddr==1.3.0
 netaddr==1.3.0
 nh3==0.2.18
 nh3==0.2.18
 Pillow==10.4.0
 Pillow==10.4.0
-psycopg[c,pool]==3.2.2
+psycopg[c,pool]==3.2.3
 PyYAML==6.0.2
 PyYAML==6.0.2
 requests==2.32.3
 requests==2.32.3
 social-auth-app-django==5.4.2
 social-auth-app-django==5.4.2