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

Merge branch 'main' into feature

Jeremy Stretch 9 месяцев назад
Родитель
Сommit
6c7a0cf2b2
79 измененных файлов с 5662 добавлено и 5667 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      docs/development/release-checklist.md
  4. 2 2
      docs/development/translations.md
  5. 4 1
      docs/installation/3-netbox.md
  6. 0 2
      docs/installation/index.md
  7. 10 5
      docs/installation/upgrading.md
  8. 30 0
      docs/release-notes/version-4.2.md
  9. 17 5
      netbox/account/views.py
  10. 5 0
      netbox/circuits/views.py
  11. 11 0
      netbox/dcim/filtersets.py
  12. 12 1
      netbox/dcim/forms/filtersets.py
  13. 13 8
      netbox/dcim/forms/object_create.py
  14. 1 2
      netbox/dcim/svg/cables.py
  15. 1 1
      netbox/dcim/tables/template_code.py
  16. 3 1
      netbox/dcim/tests/test_filtersets.py
  17. 55 5
      netbox/dcim/views.py
  18. 1 1
      netbox/extras/forms/bulk_import.py
  19. 3 8
      netbox/extras/scripts.py
  20. 13 1
      netbox/ipam/filtersets.py
  21. 6 1
      netbox/ipam/forms/filtersets.py
  22. 0 1
      netbox/ipam/forms/model_forms.py
  23. 16 2
      netbox/ipam/tests/test_filtersets.py
  24. 6 1
      netbox/netbox/middleware.py
  25. 8 0
      netbox/netbox/models/features.py
  26. 1 2
      netbox/project-static/bundle.js
  27. 0 0
      netbox/project-static/dist/netbox.css
  28. 0 0
      netbox/project-static/dist/netbox.js
  29. 0 0
      netbox/project-static/dist/netbox.js.map
  30. 1 1
      netbox/project-static/package.json
  31. 7 5
      netbox/project-static/src/select/config.ts
  32. 6 0
      netbox/project-static/styles/overrides/_bootstrap.scss
  33. 4 4
      netbox/project-static/yarn.lock
  34. 1 1
      netbox/templates/dcim/cable_edit.html
  35. 3 1
      netbox/templates/dcim/device_edit.html
  36. 3 0
      netbox/templates/dcim/htmx/cable_edit.html
  37. 23 6
      netbox/templates/dcim/inc/cable_termination.html
  38. 4 0
      netbox/templates/dcim/virtualchassis_add.html
  39. 4 0
      netbox/templates/dcim/virtualchassis_edit.html
  40. 1 1
      netbox/templates/django/forms/widgets/select.html
  41. 7 4
      netbox/templates/extras/object_render_config.html
  42. 4 0
      netbox/templates/ipam/vlan_edit.html
  43. 1 0
      netbox/templates/virtualization/cluster.html
  44. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  45. 203 197
      netbox/translations/cs/LC_MESSAGES/django.po
  46. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  47. 203 197
      netbox/translations/da/LC_MESSAGES/django.po
  48. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  49. 203 197
      netbox/translations/de/LC_MESSAGES/django.po
  50. 2452 2813
      netbox/translations/en/LC_MESSAGES/django.po
  51. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  52. 203 197
      netbox/translations/es/LC_MESSAGES/django.po
  53. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  54. 194 194
      netbox/translations/fr/LC_MESSAGES/django.po
  55. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  56. 203 197
      netbox/translations/it/LC_MESSAGES/django.po
  57. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  58. 205 199
      netbox/translations/ja/LC_MESSAGES/django.po
  59. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  60. 203 197
      netbox/translations/nl/LC_MESSAGES/django.po
  61. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  62. 203 197
      netbox/translations/pl/LC_MESSAGES/django.po
  63. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  64. 203 197
      netbox/translations/pt/LC_MESSAGES/django.po
  65. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  66. 205 199
      netbox/translations/ru/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  68. 203 197
      netbox/translations/tr/LC_MESSAGES/django.po
  69. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  70. 206 200
      netbox/translations/uk/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  72. 203 197
      netbox/translations/zh/LC_MESSAGES/django.po
  73. 2 1
      netbox/utilities/querydict.py
  74. 8 0
      netbox/utilities/string.py
  75. 15 2
      netbox/virtualization/forms/filtersets.py
  76. 6 0
      netbox/virtualization/tests/test_filtersets.py
  77. 32 8
      netbox/virtualization/views.py
  78. 2 2
      requirements.txt
  79. 15 3
      upgrade.sh

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

@@ -15,7 +15,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v4.2.7
+      placeholder: v4.2.8
     validations:
       required: true
   - type: dropdown

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

@@ -27,7 +27,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v4.2.7
+      placeholder: v4.2.8
     validations:
       required: true
   - type: dropdown

+ 1 - 1
docs/development/release-checklist.md

@@ -150,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
 Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
 
 ```no-highlight
-tx pull
+tx pull --force
 ```
 
 Then, compile these portable (`.po`) files for use in the application:

+ 2 - 2
docs/development/translations.md

@@ -33,10 +33,10 @@ To download translated strings automatically, you'll need to:
 Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
 
 ```no-highlight
-TX_TOKEN=$TOKEN tx pull
+TX_TOKEN=$TOKEN tx pull --force
 ```
 
-This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
+This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.)
 
 Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
 

+ 4 - 1
docs/installation/3-netbox.md

@@ -250,7 +250,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
 
 * Create a Python virtual environment
 * Installs all required Python packages
-* Run database schema migrations
+* Run database schema migrations (skip with `--readonly`)
 * Builds the documentation locally (for offline use)
 * Aggregate static resource files on disk
 
@@ -270,6 +270,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
 !!! note
     Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
 
+!!! note
+    To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
+
 ## Create a Super User
 
 NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:

+ 0 - 2
docs/installation/index.md

@@ -5,8 +5,6 @@
 
 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>
-
 The following sections detail how to set up a new instance of NetBox:
 
 1. [PostgreSQL database](1-postgresql.md)

+ 10 - 5
docs/installation/upgrading.md

@@ -122,17 +122,19 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
 
 ### Option B: Check Out a Git Release
 
-This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
+This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
 
 ```
-sudo git fetch --tags
-git describe --tags $(git rev-list --tags --max-count=1)
+git ls-remote --tags https://github.com/netbox-community/netbox.git \
+  | grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \
+  | tail -n 1 \
+  | sed 's|refs/tags/||'
 ```
 
-Check out the desired release by specifying its tag:
+Check out the desired release by specifying its tag. For example:
 
 ```
-sudo git checkout v4.2.0
+sudo git checkout v4.2.7
 ```
 
 ## 4. Run the Upgrade Script
@@ -150,6 +152,9 @@ sudo ./upgrade.sh
     sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
     ```
 
+!!! note
+    To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
+
 This script performs the following actions:
 
 * Destroys and rebuilds the Python virtual environment

+ 30 - 0
docs/release-notes/version-4.2.md

@@ -1,5 +1,35 @@
 # NetBox v4.2
 
+## v4.2.8 (2025-04-22)
+
+### Enhancements
+
+* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script
+* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view
+* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN
+* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists
+* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form
+* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view
+* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config
+* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug
+* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms
+
+### Bug Fixes
+
+* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model
+* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API
+* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views
+* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields
+* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces
+* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members
+* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices`
+* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader
+* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value
+* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern
+* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms
+
+---
+
 ## v4.2.7 (2025-04-10)
 
 ### Enhancements

+ 17 - 5
netbox/account/views.py

@@ -28,6 +28,7 @@ from netbox.config import get_config
 from netbox.views import generic
 from users import forms, tables
 from users.models import UserConfig
+from utilities.string import remove_linebreaks
 from utilities.views import register_model_view
 
 
@@ -125,12 +126,18 @@ class LoginView(View):
 
             # Set the user's preferred language (if any)
             if language := request.user.config.get('locale.language'):
-                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
+                response.set_cookie(
+                    key=settings.LANGUAGE_COOKIE_NAME,
+                    value=language,
+                    max_age=request.session.get_expiry_age(),
+                    secure=settings.SESSION_COOKIE_SECURE,
+                )
 
             return response
 
         else:
-            logger.debug(f"Login form validation failed for username: {form['username'].value()}")
+            username = form['username'].value()
+            logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
 
         return render(request, self.template_name, {
             'form': form,
@@ -142,10 +149,10 @@ class LoginView(View):
         redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
 
         if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
-            logger.debug(f"Redirecting user to {redirect_url}")
+            logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
         else:
             if redirect_url:
-                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
+                logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
             redirect_url = reverse('home')
 
         return HttpResponseRedirect(redirect_url)
@@ -220,7 +227,12 @@ class UserConfigView(LoginRequiredMixin, View):
 
             # Set/clear language cookie
             if language := form.cleaned_data['locale.language']:
-                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
+                response.set_cookie(
+                    key=settings.LANGUAGE_COOKIE_NAME,
+                    value=language,
+                    max_age=request.session.get_expiry_age(),
+                    secure=settings.SESSION_COOKIE_SECURE,
+                )
             else:
                 response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
 

+ 5 - 0
netbox/circuits/views.py

@@ -159,11 +159,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
             'related_models': self.get_related_models(
                 request,
                 instance,
+                omit=(CircuitTermination,),
                 extra=(
                     (
                         Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
                         'provider_network_id',
                     ),
+                    (
+                        CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
+                        'provider_network_id',
+                    ),
                 ),
             ),
         }

+ 11 - 0
netbox/dcim/filtersets.py

@@ -1110,6 +1110,13 @@ class DeviceFilterSet(
         lookup_expr='in',
         label=_('Location (ID)'),
     )
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='location',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Location (slug)'),
+    )
     rack_id = django_filters.ModelMultipleChoiceFilter(
         field_name='rack',
         queryset=Rack.objects.all(),
@@ -1739,6 +1746,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
 
 
 class CommonInterfaceFilterSet(django_filters.FilterSet):
+    mode = django_filters.MultipleChoiceFilter(
+        choices=InterfaceModeChoices,
+        label=_('802.1Q Mode')
+    )
     vlan_id = django_filters.CharFilter(
         method='filter_vlan_id',
         label=_('Assigned VLAN')

+ 12 - 1
netbox/dcim/forms/filtersets.py

@@ -6,7 +6,7 @@ from dcim.constants import *
 from dcim.models import *
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
-from ipam.models import ASN, VRF
+from ipam.models import ASN, VRF, VLANTranslationPolicy
 from netbox.choices import *
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -1356,6 +1356,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
+        FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
@@ -1427,6 +1428,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         required=False,
         label=_('PoE type')
     )
+    mode = forms.MultipleChoiceField(
+        choices=InterfaceModeChoices,
+        required=False,
+        label=_('802.1Q mode')
+    )
+    vlan_translation_policy_id = DynamicModelMultipleChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
     rf_role = forms.MultipleChoiceField(
         choices=WirelessRoleChoices,
         required=False,

+ 13 - 8
netbox/dcim/forms/object_create.py

@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
     def clean(self):
         super().clean()
 
-        # Validate that all replication fields generate an equal number of values
+        # Validate that all replication fields generate an equal number of values (or a single value)
         if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
             return
-
         pattern_count = len(patterns)
         for field_name in self.replication_fields:
             value_count = len(self.cleaned_data[field_name])
-            if self.cleaned_data[field_name] and value_count != pattern_count:
-                raise forms.ValidationError({
-                    field_name: _(
-                        "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
-                    ).format(value_count=value_count, pattern_count=pattern_count)
-                }, code='label_pattern_mismatch')
+            if self.cleaned_data[field_name]:
+                if value_count == 1:
+                    # If the field resolves to a single value (because no pattern was used), multiply it by the number
+                    # of expected values. This allows us to reuse the same label when creating multiple components.
+                    self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
+                elif value_count != pattern_count:
+                    raise forms.ValidationError({
+                        field_name: _(
+                            "The provided pattern specifies {value_count} values, but {pattern_count} are expected."
+                        ).format(value_count=value_count, pattern_count=pattern_count)
+                    }, code='label_pattern_mismatch')
 
 
 #
@@ -404,6 +408,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
         queryset=Device.objects.all(),
         required=False,
         query_params={
+            'virtual_chassis_id': 'null',
             'site_id': '$site',
             'rack_id': '$rack',
         }

+ 1 - 2
netbox/dcim/svg/cables.py

@@ -225,8 +225,7 @@ class CableTraceSVG:
         """
         nodes_height = 0
         nodes = []
-        # Sort them by name to make renders more readable
-        for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
+        for i, term in enumerate(terminations):
             node = Node(
                 position=(offset_x + i * width, self.cursor),
                 width=width,

+ 1 - 1
netbox/dcim/tables/template_code.py

@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
 
 INTERFACE_FHRPGROUPS = """
   {% for assignment in value.all %}
-    <a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
+    <a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
   {% endfor %}
 """
 

+ 3 - 1
netbox/dcim/tests/test_filtersets.py

@@ -2801,6 +2801,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         locations = Location.objects.all()[:2]
         params = {'location_id': [locations[0].pk, locations[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_rack(self):
         racks = Rack.objects.all()[:2]
@@ -4416,7 +4418,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_mode(self):
-        params = {'mode': InterfaceModeChoices.MODE_ACCESS}
+        params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
     def test_description(self):

+ 55 - 5
netbox/dcim/views.py

@@ -13,7 +13,7 @@ from django.views.generic import View
 
 from circuits.models import Circuit, CircuitTermination
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
-from ipam.models import ASN, IPAddress, Prefix, VLANGroup
+from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
@@ -236,7 +236,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
             'related_models': self.get_related_models(
                 request,
                 regions,
-                omit=(Cluster, Prefix, WirelessLAN),
+                omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
                     (Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
@@ -246,8 +246,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
                         ).distinct(),
                         'region_id'
                     ),
+                    (
+                        VLANGroup.objects.restrict(request.user, 'view').filter(
+                            scope_type=ContentType.objects.get_for_model(Region),
+                            scope_id__in=regions
+                        ).distinct(),
+                        'region'
+                    ),
 
                     # Handle these relations manually to avoid erroneous filter name resolution
+                    (
+                        CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
+                        'region_id'
+                    ),
                     (Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
                     (Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
                     (WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
@@ -330,10 +341,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
             'related_models': self.get_related_models(
                 request,
                 groups,
-                omit=(Cluster, Prefix, WirelessLAN),
+                omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
                 extra=(
                     (Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
                     (Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                    (Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                    (VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
+                    (
+                        ASN.objects.restrict(request.user, 'view').filter(
+                            sites__group__in=groups
+                        ).distinct(),
+                        'site_group_id'),
+                    (
+                        VirtualMachine.objects.restrict(request.user, 'view').filter(
+                            site__group__in=groups),
+                        'site_group_id'
+                    ),
+                    (
+                        VLANGroup.objects.restrict(request.user, 'view').filter(
+                            scope_type=ContentType.objects.get_for_model(SiteGroup),
+                            scope_id__in=groups
+                        ).distinct(),
+                        'site_group'
+                    ),
                     (
                         Circuit.objects.restrict(request.user, 'view').filter(
                             terminations___site_group=instance
@@ -342,6 +372,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
                     ),
 
                     # Handle these relations manually to avoid erroneous filter name resolution
+                    (
+                        CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
+                        'site_group_id'
+                    ),
                     (
                         Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
                         'site_group_id'
@@ -444,6 +478,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
                     (Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
                     (Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
                     (WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
+                    (CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
                 ),
             ),
         }
@@ -523,7 +558,7 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
             'related_models': self.get_related_models(
                 request,
                 locations,
-                omit=[CableTermination, Cluster, Prefix, WirelessLAN],
+                omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
                 extra=(
                     (
                         Circuit.objects.restrict(request.user, 'view').filter(
@@ -533,6 +568,10 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
                     ),
 
                     # Handle these relations manually to avoid erroneous filter name resolution
+                    (
+                        CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
+                        'location_id'
+                    ),
                     (Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
                     (Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
                     (WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
@@ -793,7 +832,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
         ])
 
         return {
-            'related_models': self.get_related_models(request, instance, [CableTermination]),
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                omit=(CableTermination,),
+                extra=(
+                    (
+                    VLANGroup.objects.restrict(request.user, 'view').filter(
+                        scope_type=ContentType.objects.get_for_model(Rack),
+                        scope_id=instance.pk
+                    ), 'rack'),
+                ),
+            ),
             'next_rack': next_rack,
             'prev_rack': prev_rack,
             'svg_extra': svg_extra,

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

@@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
     class Meta:
         model = CustomFieldChoiceSet
         fields = (
-            'name', 'description', 'extra_choices', 'order_alphabetically',
+            'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
         )
 
     def clean_extra_choices(self):

+ 3 - 8
netbox/extras/scripts.py

@@ -566,28 +566,23 @@ class BaseScript:
     def load_yaml(self, filename):
         """
         Return data from a YAML file
-        TODO: DEPRECATED: Remove this method in v4.4
         """
+        # TODO: DEPRECATED: Remove this method in v4.4
         self._log(
             _("load_yaml is deprecated and will be removed in v4.4"),
             level=LogLevelChoices.LOG_WARNING
         )
-        try:
-            from yaml import CLoader as Loader
-        except ImportError:
-            from yaml import Loader
-
         file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
         with open(file_path, 'r') as datafile:
-            data = yaml.load(datafile, Loader=Loader)
+            data = yaml.load(datafile, Loader=yaml.SafeLoader)
 
         return data
 
     def load_json(self, filename):
         """
         Return data from a JSON file
-        TODO: DEPRECATED: Remove this method in v4.4
         """
+        # TODO: DEPRECATED: Remove this method in v4.4
         self._log(
             _("load_json is deprecated and will be removed in v4.4"),
             level=LogLevelChoices.LOG_WARNING

+ 13 - 1
netbox/ipam/filtersets.py

@@ -351,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
         to_field_name='rd',
         label=_('VRF (RD)'),
     )
+    vlan_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan__group',
+        queryset=VLANGroup.objects.all(),
+        to_field_name="id",
+        label=_('VLAN Group (ID)'),
+    )
+    vlan_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='vlan__group__slug',
+        queryset=VLANGroup.objects.all(),
+        to_field_name="slug",
+        label=_('VLAN Group (slug)'),
+    )
     vlan_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VLAN.objects.all(),
         label=_('VLAN (ID)'),
@@ -1150,7 +1162,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
         return queryset.filter(qs_filter)
 
 
-class ServiceFilterSet(NetBoxModelFilterSet):
+class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
     device = MultiValueCharFilter(
         method='filter_device',
         field_name='name',

+ 6 - 1
netbox/ipam/forms/filtersets.py

@@ -176,7 +176,7 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
             'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
             name=_('Addressing')
         ),
-        FieldSet('vlan_id', name=_('VLAN Assignment')),
+        FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
@@ -260,6 +260,11 @@ class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFil
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    vlan_group_id = DynamicModelMultipleChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label=_('VLAN Group'),
+    )
     vlan_id = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         required=False,

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

@@ -538,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
                 role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
                 assigned_object=instance
             )
-            ipaddress.populate_custom_field_defaults()
             ipaddress.save()
 
             # Check that the new IPAddress conforms with any assigned object-level permissions

+ 16 - 2
netbox/ipam/tests/test_filtersets.py

@@ -645,9 +645,16 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         vrfs[1].export_targets.add(route_targets[1])
         vrfs[2].export_targets.add(route_targets[2])
 
+        vlan_groups = (
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
+        )
+        for vlan_group in vlan_groups:
+            vlan_group.save()
+
         vlans = (
-            VLAN(vid=1, name='VLAN 1'),
-            VLAN(vid=2, name='VLAN 2'),
+            VLAN(vid=1, name='VLAN 1', group=vlan_groups[0]),
+            VLAN(vid=2, name='VLAN 2', group=vlan_groups[1]),
             VLAN(vid=3, name='VLAN 3'),
         )
         VLAN.objects.bulk_create(vlans)
@@ -850,6 +857,13 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_vlan_group(self):
+        vlan_groups = VLANGroup.objects.all()[:2]
+        params = {'vlan_group_id': [vlan_groups[0].pk, vlan_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
     def test_vlan(self):
         vlans = VLAN.objects.all()[:2]
         params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}

+ 6 - 1
netbox/netbox/middleware.py

@@ -47,7 +47,12 @@ class CoreMiddleware:
         # Check if language cookie should be renewed
         if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
             if language := request.user.config.get('locale.language'):
-                response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
+                response.set_cookie(
+                    key=settings.LANGUAGE_COOKIE_NAME,
+                    value=language,
+                    max_age=request.session.get_expiry_age(),
+                    secure=settings.SESSION_COOKIE_SECURE,
+                )
 
         # Attach the unique request ID as an HTTP header.
         response['X-Request-ID'] = request.id

+ 8 - 0
netbox/netbox/models/features.py

@@ -301,6 +301,14 @@ class CustomFieldsMixin(models.Model):
             if cf.required and cf.name not in self.custom_field_data:
                 raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
+    def save(self, *args, **kwargs):
+        # Populate default values if omitted
+        for cf in self.custom_fields.filter(default__isnull=False):
+            if cf.name not in self.custom_field_data:
+                self.custom_field_data[cf.name] = cf.default
+
+        super().save(*args, **kwargs)
+
 
 class CustomLinksMixin(models.Model):
     """

+ 1 - 2
netbox/project-static/bundle.js

@@ -9,8 +9,7 @@ const options = {
   outdir: './dist',
   bundle: true,
   minify: true,
-  sourcemap: 'external',
-  sourcesContent: false,
+  sourcemap: 'linked',
   logLevel: 'error',
 };
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/package.json

@@ -30,7 +30,7 @@
     "gridstack": "12.0.0",
     "htmx.org": "2.0.4",
     "query-string": "9.1.1",
-    "sass": "1.86.3",
+    "sass": "1.87.0",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"

+ 7 - 5
netbox/project-static/src/select/config.ts

@@ -5,11 +5,13 @@ interface PluginConfig {
 export function getPlugins(element: HTMLSelectElement): object {
   const plugins: PluginConfig = {};
 
-  // Enable "clear all" button
-  plugins.clear_button = {
-    html: (data: Dict) =>
-      `<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
-  };
+  // Enable "clear all" button for non-required fields
+  if (!element.required) {
+    plugins.clear_button = {
+      html: (data: Dict) =>
+        `<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
+    };
+  }
 
   // Enable individual "remove" buttons for items on multi-select fields
   if (element.hasAttribute('multiple')) {

+ 6 - 0
netbox/project-static/styles/overrides/_bootstrap.scss

@@ -3,6 +3,12 @@ html {
   scroll-behavior: auto !important;
 }
 
+// Remove horizontal padding from highlighted text
+mark {
+  padding-left: 0;
+  padding-right: 0;
+}
+
 // Prevent dropdown menus from being clipped inside responsive tables
 .table-responsive {
   .dropdown, .btn-group, .btn-group-vertical {

+ 4 - 4
netbox/project-static/yarn.lock

@@ -2678,10 +2678,10 @@ safe-regex-test@^1.0.3:
     es-errors "^1.3.0"
     is-regex "^1.1.4"
 
-sass@1.86.3:
-  version "1.86.3"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.3.tgz#0a0d9ea97cb6665e73f409639f8533ce057464c9"
-  integrity sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==
+sass@1.87.0:
+  version "1.87.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e"
+  integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==
   dependencies:
     chokidar "^4.0.0"
     immutable "^5.0.2"

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

@@ -1,5 +1,5 @@
 {% extends 'generic/object_edit.html' %}
 
 {% block form %}
-  {%  include 'dcim/htmx/cable_edit.html' %}
+  {% include 'dcim/htmx/cable_edit.html' %}
 {% endblock %}

+ 3 - 1
netbox/templates/dcim/device_edit.html

@@ -3,7 +3,9 @@
 {% load i18n %}
 
 {% block form %}
-    {% render_errors form %}
+    {% for field in form.hidden_fields %}
+      {{ field }}
+    {% endfor %}
 
     <div class="field-group my-5">
       <div class="row">

+ 3 - 0
netbox/templates/dcim/htmx/cable_edit.html

@@ -3,6 +3,9 @@
 {% load form_helpers %}
 {% load i18n %}
 
+{% for field in form.hidden_fields %}
+  {{ field }}
+{% endfor %}
 
 {# A side termination #}
 <div class="field-group mb-5">

+ 23 - 6
netbox/templates/dcim/inc/cable_termination.html

@@ -20,10 +20,15 @@
         <th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
         <td>
           {% for term in terminations %}
-	    {{term.device|linkify}}
-	    <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
-	    {{ term|linkify }}
-	    {% if not forloop.last %}<br/>{% endif %}
+            {{ term.device|linkify }}
+            <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
+            {{ term|linkify }}
+            {% with trace_url=term|viewname:"trace" %}
+              <a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
+              </a>
+            {% endwith %}
+            {% if not forloop.last %}<br/>{% endif %}
           {% endfor %}
         </td>
       </tr>
@@ -41,7 +46,13 @@
         <th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
         <td>
           {% for term in terminations %}
-            {{ term|linkify }}{% if not forloop.last %},{% endif %}
+            {{ term|linkify }}
+            {% with trace_url=term|viewname:"trace" %}
+              <a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
+              </a>
+            {% endwith %}
+            {% if not forloop.last %}<br/>{% endif %}
           {% endfor %}
         </td>
       </tr>
@@ -55,7 +66,13 @@
         <th scope="row">{% trans "Circuit" %}</th>
         <td>
           {% for term in terminations %}
-            {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
+            {{ term.circuit|linkify }} ({{ term }})
+            {% with trace_url=term|viewname:"trace" %}
+              <a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
+                <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
+              </a>
+            {% endwith %}
+            {% if not forloop.last %}<br/>{% endif %}
           {% endfor %}
         </td>
       </tr>

+ 4 - 0
netbox/templates/dcim/virtualchassis_add.html

@@ -3,6 +3,10 @@
 {% load i18n %}
 
 {% block form %}
+  {% for field in form.hidden_fields %}
+    {{ field }}
+  {% endfor %}
+
   <div class="field-group my-5">
     <div class="row">
       <h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>

+ 4 - 0
netbox/templates/dcim/virtualchassis_edit.html

@@ -12,11 +12,15 @@
 {% block content %}
   <div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
     <form action="" method="post" enctype="multipart/form-data" class="object-edit">
+      {% render_errors vc_form %}
       {% for form in formset %}
         {% render_errors form %}
       {% endfor %}
 
       {% csrf_token %}
+      {% for field in vc_form.hidden_fields %}
+        {{ field }}
+      {% endfor %}
       {{ pk_form.pk }}
       {{ formset.management_form }}
       <div class="field-group my-5">

+ 1 - 1
netbox/templates/django/forms/widgets/select.html

@@ -1,4 +1,4 @@
-<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}"{% if widget.required %} required{% endif %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
   <optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
   {% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
   </optgroup>{% endif %}{% endfor %}

+ 7 - 4
netbox/templates/extras/object_render_config.html

@@ -54,11 +54,14 @@
           <div class="card">
             <h2 class="card-header d-flex justify-content-between">
               {% trans "Rendered Config" %}
-              <a href="?export=True" class="btn btn-primary lh-1" role="button">
-                <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
-              </a>
+              <div>
+                {% copy_content "rendered_config" %}
+                <a href="?export=True" class="btn btn-primary lh-1" role="button">
+                  <i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
+                </a>
+              </div>
             </h2>
-            <pre class="card-body">{{ rendered_config }}</pre>
+            <pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
           </div>
         {% else %}
           <div class="alert alert-warning">

+ 4 - 0
netbox/templates/ipam/vlan_edit.html

@@ -5,6 +5,10 @@
 {% load i18n %}
 
 {% block form %}
+  {% for field in form.hidden_fields %}
+    {{ field }}
+  {% endfor %}
+
   <div class="field-group my-5">
     <div class="row">
       <h2 class="col-9 offset-3">{% trans "VLAN" %}</h2>

+ 1 - 0
netbox/templates/virtualization/cluster.html

@@ -81,6 +81,7 @@
           </tr>
       </table>
   </div>
+    {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/tags.html' %}
     {% plugin_right_page object %}

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/de/LC_MESSAGES/django.po


Разница между файлами не показана из-за своего большого размера
+ 2452 - 2813
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 194 - 194
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 205 - 199
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 205 - 199
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 206 - 200
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


Разница между файлами не показана из-за своего большого размера
+ 203 - 197
netbox/translations/zh/LC_MESSAGES/django.po


+ 2 - 1
netbox/utilities/querydict.py

@@ -2,6 +2,7 @@ from urllib.parse import urlencode
 
 from django.http import QueryDict
 from django.utils.datastructures import MultiValueDict
+from netbox.models import CloningMixin
 
 __all__ = (
     'dict_to_querydict',
@@ -46,7 +47,7 @@ def prepare_cloned_fields(instance):
     Generate a QueryDict comprising attributes from an object's clone() method.
     """
     # Generate the clone attributes from the instance
-    if not hasattr(instance, 'clone'):
+    if not issubclass(type(instance), CloningMixin):
         return QueryDict(mutable=True)
     attrs = instance.clone()
 

+ 8 - 0
netbox/utilities/string.py

@@ -2,6 +2,7 @@ import re
 
 __all__ = (
     'enum_key',
+    'remove_linebreaks',
     'title',
     'trailing_slash',
 )
@@ -15,6 +16,13 @@ def enum_key(value):
     return re.sub(r'[^_A-Z0-9]', '_', value)
 
 
+def remove_linebreaks(value):
+    """
+    Remove all line breaks from a string and return the result. Useful for log sanitization purposes.
+    """
+    return value.replace('\n', '').replace('\r', '')
+
+
 def title(value):
     """
     Improved implementation of str.title(); retains all existing uppercase letters.

+ 15 - 2
netbox/virtualization/forms/filtersets.py

@@ -1,10 +1,11 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
+from dcim.choices import *
 from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
-from ipam.models import VRF
+from ipam.models import VRF, VLANTranslationPolicy
 from netbox.forms import NetBoxModelFilterSetForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES
@@ -200,7 +201,9 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('cluster_id', 'virtual_machine_id', name=_('Virtual Machine')),
-        FieldSet('enabled', 'mac_address', 'vrf_id', 'l2vpn_id', name=_('Attributes')),
+        FieldSet('enabled', name=_('Attributes')),
+        FieldSet('vrf_id', 'l2vpn_id', 'mac_address', name=_('Addressing')),
+        FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
     )
     selector_fields = ('filter_id', 'q', 'virtual_machine_id')
     cluster_id = DynamicModelMultipleChoiceField(
@@ -237,6 +240,16 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
         required=False,
         label=_('L2VPN')
     )
+    mode = forms.MultipleChoiceField(
+        choices=InterfaceModeChoices,
+        required=False,
+        label=_('802.1Q mode')
+    )
+    vlan_translation_policy_id = DynamicModelMultipleChoiceField(
+        queryset=VLANTranslationPolicy.objects.all(),
+        required=False,
+        label=_('VLAN Translation Policy')
+    )
     tag = TagFilterField(model)
 
 

+ 6 - 0
netbox/virtualization/tests/test_filtersets.py

@@ -606,6 +606,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=100,
                 vrf=vrfs[0],
                 description='foobar1',
+                mode=InterfaceModeChoices.MODE_ACCESS,
                 vlan_translation_policy=vlan_translation_policies[0],
             ),
             VMInterface(
@@ -615,6 +616,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
                 mtu=200,
                 vrf=vrfs[1],
                 description='foobar2',
+                mode=InterfaceModeChoices.MODE_TAGGED,
                 vlan_translation_policy=vlan_translation_policies[0],
             ),
             VMInterface(
@@ -700,6 +702,10 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_mode(self):
+        params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_vlan(self):
         vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
         params = {'vlan_id': vlan.pk}

+ 32 - 8
netbox/virtualization/views.py

@@ -1,4 +1,5 @@
 from django.contrib import messages
+from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 from django.db.models import Prefetch, Sum
 from django.shortcuts import get_object_or_404, redirect, render
@@ -10,7 +11,7 @@ from dcim.forms import DeviceFilterForm
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLANGroup
 from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
 from netbox.views import generic
@@ -102,7 +103,17 @@ class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
 
     def get_extra_context(self, request, instance):
         return {
-            'related_models': self.get_related_models(request, instance),
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                extra=(
+                    (
+                    VLANGroup.objects.restrict(request.user, 'view').filter(
+                        scope_type=ContentType.objects.get_for_model(ClusterGroup),
+                        scope_id=instance.pk
+                    ), 'cluster_group'),
+                ),
+            ),
         }
 
 
@@ -162,15 +173,28 @@ class ClusterListView(generic.ObjectListView):
 
 
 @register_model_view(Cluster)
-class ClusterView(generic.ObjectView):
+class ClusterView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Cluster.objects.all()
 
     def get_extra_context(self, request, instance):
-        return instance.virtual_machines.aggregate(
-            vcpus_sum=Sum('vcpus'),
-            memory_sum=Sum('memory'),
-            disk_sum=Sum('disk')
-        )
+        return {
+            **instance.virtual_machines.aggregate(
+                vcpus_sum=Sum('vcpus'),
+                memory_sum=Sum('memory'),
+                disk_sum=Sum('disk')
+            ),
+            'related_models': self.get_related_models(
+                request,
+                instance,
+                omit=(),
+                extra=(
+                    (VLANGroup.objects.restrict(request.user, 'view').filter(
+                        scope_type=ContentType.objects.get_for_model(Cluster),
+                        scope_id=instance.pk
+                    ), 'cluster'),
+                )
+                ),
+        }
 
 
 @register_model_view(Cluster, 'virtualmachines', path='virtual-machines')

+ 2 - 2
requirements.txt

@@ -22,7 +22,7 @@ gunicorn==23.0.0
 Jinja2==3.1.6
 jsonschema==4.23.0
 Markdown==3.8
-mkdocs-material==9.6.11
+mkdocs-material==9.6.12
 mkdocstrings[python]==0.29.1
 netaddr==1.3.0
 nh3==0.2.21
@@ -33,7 +33,7 @@ requests==2.32.3
 rq==2.3.2
 social-auth-app-django==5.4.3
 social-auth-core==4.5.6
-strawberry-graphql==0.264.0
+strawberry-graphql==0.266.0
 strawberry-graphql-django==0.58.0
 svgwrite==1.4.3
 tablib==3.8.0

+ 15 - 3
upgrade.sh

@@ -6,6 +6,13 @@
 # variable (if set), or fall back to "python3". Note that NetBox v4.0+ requires
 # Python 3.10 or later.
 
+# Parse arguments
+if [[ "$1" == "--readonly" ]]; then
+  READONLY_MODE=true
+else
+  READONLY_MODE=false
+fi
+
 cd "$(dirname "$0")"
 
 NETBOX_VERSION="$(grep ^version netbox/release.yaml | cut -d \" -f2)"
@@ -83,9 +90,14 @@ else
 fi
 
 # Apply any database migrations
-COMMAND="python3 netbox/manage.py migrate"
-echo "Applying database migrations ($COMMAND)..."
-eval $COMMAND || exit 1
+if [ "$READONLY_MODE" = true ]; then
+  echo "Skipping database migrations (read-only mode)"
+  exit 0
+else
+  COMMAND="python3 netbox/manage.py migrate"
+  echo "Applying database migrations ($COMMAND)..."
+  eval $COMMAND || exit 1
+fi
 
 # Trace any missing cable paths (not typically needed)
 COMMAND="python3 netbox/manage.py trace_paths --no-input"

Некоторые файлы не были показаны из-за большого количества измененных файлов