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

Merge pull request #17157 from netbox-community/develop

Release v4.0.9
Jeremy Stretch 1 год назад
Родитель
Сommit
09d6b9c62f
46 измененных файлов с 4103 добавлено и 2404 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 4 2
      CONTRIBUTING.md
  4. 1 1
      base_requirements.txt
  5. 1 0
      contrib/generated_schema.json
  6. 21 0
      docs/release-notes/version-4.0.md
  7. 4 4
      netbox/account/views.py
  8. 5 2
      netbox/circuits/views.py
  9. 17 13
      netbox/core/views.py
  10. 2 0
      netbox/dcim/choices.py
  11. 1 0
      netbox/dcim/constants.py
  12. 27 22
      netbox/dcim/forms/bulk_edit.py
  13. 6 3
      netbox/dcim/forms/connections.py
  14. 24 6
      netbox/dcim/views.py
  15. 2 4
      netbox/extras/models/customfields.py
  16. 15 1
      netbox/ipam/forms/bulk_edit.py
  17. 1 1
      netbox/netbox/settings.py
  18. 2 1
      netbox/netbox/tables/tables.py
  19. 26 0
      netbox/netbox/tests/test_import.py
  20. 44 8
      netbox/netbox/views/generic/bulk_views.py
  21. 9 4
      netbox/netbox/views/generic/feature_views.py
  22. 0 0
      netbox/project-static/dist/netbox.css
  23. 0 0
      netbox/project-static/dist/netbox.js
  24. 0 0
      netbox/project-static/dist/netbox.js.map
  25. 3 1
      netbox/project-static/src/messages.ts
  26. 4 0
      netbox/project-static/styles/overrides/_tabler.scss
  27. 15 8
      netbox/project-static/yarn.lock
  28. 6 1
      netbox/templates/core/rq_worker_list.html
  29. 266 140
      netbox/translations/cs/LC_MESSAGES/django.po
  30. 248 176
      netbox/translations/da/LC_MESSAGES/django.po
  31. 266 136
      netbox/translations/de/LC_MESSAGES/django.po
  32. 263 137
      netbox/translations/en/LC_MESSAGES/django.po
  33. 254 180
      netbox/translations/es/LC_MESSAGES/django.po
  34. 254 180
      netbox/translations/fr/LC_MESSAGES/django.po
  35. 242 167
      netbox/translations/it/LC_MESSAGES/django.po
  36. 266 140
      netbox/translations/ja/LC_MESSAGES/django.po
  37. 270 139
      netbox/translations/nl/LC_MESSAGES/django.po
  38. 268 139
      netbox/translations/pl/LC_MESSAGES/django.po
  39. 269 140
      netbox/translations/pt/LC_MESSAGES/django.po
  40. 241 170
      netbox/translations/ru/LC_MESSAGES/django.po
  41. 254 180
      netbox/translations/tr/LC_MESSAGES/django.po
  42. 245 144
      netbox/translations/uk/LC_MESSAGES/django.po
  43. 240 140
      netbox/translations/zh/LC_MESSAGES/django.po
  44. 2 1
      netbox/utilities/filters.py
  45. 7 5
      netbox/virtualization/views.py
  46. 6 6
      requirements.txt

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

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

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

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

+ 4 - 2
CONTRIBUTING.md

@@ -40,7 +40,7 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
 
 * First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
 
-* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the bottom left corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
+* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the bottom left corner of the issue and add a thumbs up ( :thumbsup: ). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
 
 * If you can't find any existing issues (open or closed) that seem to match yours, you're welcome to [submit a new bug report](https://github.com/netbox-community/netbox/issues/new?label=type%3A+bug&template=bug_report.yaml). Be sure to complete the entire report template, including detailed steps that someone triaging your issue can follow to confirm the reported behavior. (If we're not able to replicate the bug based on the information provided, we'll ask for additional detail.)
 
@@ -56,7 +56,9 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
 
 ## :bulb: Feature Requests
 
-* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
+* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up ( :thumbsup: ). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
+
+* Please don't submit duplicate issues! Sometimes we reject feature requests, for various reasons. Even if you disagree with those reasons, please **do not** submit a duplicate feature request. It is very disrepectful of the maintainers' time, and you may be barred from opening future issues.
 
 * If you have a rough idea that's not quite ready for formal submission yet, start a [GitHub discussion](https://github.com/netbox-community/netbox/discussions) instead. This is a great way to test the viability and narrow down the scope of a new feature prior to submitting a formal proposal, and can serve to generate interest in your idea from other community members.
 

+ 1 - 1
base_requirements.txt

@@ -10,7 +10,7 @@ django-cors-headers
 # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
 # Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
 # and https://github.com/jazzband/django-debug-toolbar/issues/1927
-django-debug-toolbar==4.3.0
+django-debug-toolbar
 
 # Library for writing reusable URL query filters
 # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst

+ 1 - 0
contrib/generated_schema.json

@@ -377,6 +377,7 @@
                         "ieee802.11ad",
                         "ieee802.11ax",
                         "ieee802.11ay",
+                        "ieee802.11be",
                         "ieee802.15.1",
                         "other-wireless",
                         "gsm",

+ 21 - 0
docs/release-notes/version-4.0.md

@@ -1,5 +1,26 @@
 # NetBox v4.0
 
+## v4.0.9 (2024-08-14)
+
+### Enhancements
+
+* [#16692](https://github.com/netbox-community/netbox/issues/16692) - Enable modifying VLAN assignment while bulk editing prefixes
+* [#17006](https://github.com/netbox-community/netbox/issues/17006) - Add IEEE 802.11be interface type
+
+### Bug Fixes
+
+* [#13459](https://github.com/netbox-community/netbox/issues/13459) - Correct OpenAPI schema type for `TreeNodeMultipleChoiceFilter`
+* [#16073](https://github.com/netbox-community/netbox/issues/16073) - Respect default values for custom fields during bulk import of objects
+* [#16176](https://github.com/netbox-community/netbox/issues/16176) - Restore ability to select multiple terminating devices when connecting a cable
+* [#16871](https://github.com/netbox-community/netbox/issues/16871) - Sanitize device ID query parameter when bulk editing components to prevent exception
+* [#17038](https://github.com/netbox-community/netbox/issues/17038) - Fix AttributeError exception when attempting to export system status data
+* [#17064](https://github.com/netbox-community/netbox/issues/17064) - Fix misaligned text within rendered Markdown code blocks
+* [#17124](https://github.com/netbox-community/netbox/issues/17124) - `BaseTable` should follow reverse one-to-one relationships when prefetching related objects
+* [#17131](https://github.com/netbox-community/netbox/issues/17131) - Fix exception when creating object-type custom field without selecting related object type
+* [#17144](https://github.com/netbox-community/netbox/issues/17144) - Avoid showing duplicated pop-up messages
+
+---
+
 ## v4.0.8 (2024-07-26)
 
 ### Enhancements

+ 4 - 4
netbox/account/views.py

@@ -109,7 +109,7 @@ class LoginView(View):
             # Authenticate user
             auth_login(request, form.get_user())
             logger.info(f"User {request.user} successfully authenticated")
-            messages.success(request, f"Logged in as {request.user}.")
+            messages.success(request, _("Logged in as {user}.").format(user=request.user))
 
             # Ensure the user has a UserConfig defined. (This should normally be handled by
             # create_userconfig() on user creation.)
@@ -159,7 +159,7 @@ class LogoutView(View):
         username = request.user
         auth_logout(request)
         logger.info(f"User {username} has logged out")
-        messages.info(request, "You have logged out.")
+        messages.info(request, _("You have logged out."))
 
         # Delete session key & language cookies (if set) upon logout
         response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
@@ -234,7 +234,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
     def get(self, request):
         # LDAP users cannot change their password here
         if getattr(request.user, 'ldap_username', None):
-            messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
+            messages.warning(request, _("LDAP-authenticated user credentials cannot be changed within NetBox."))
             return redirect('account:profile')
 
         form = PasswordChangeForm(user=request.user)
@@ -249,7 +249,7 @@ class ChangePasswordView(LoginRequiredMixin, View):
         if form.is_valid():
             form.save()
             update_session_auth_hash(request, form.user)
-            messages.success(request, "Your password has been changed successfully.")
+            messages.success(request, _("Your password has been changed successfully."))
             return redirect('account:profile')
 
         return render(request, self.template_name, {

+ 5 - 2
netbox/circuits/views.py

@@ -1,6 +1,7 @@
 from django.contrib import messages
 from django.db import transaction
 from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.translation import gettext_lazy as _
 
 from dcim.views import PathTraceView
 from netbox.views import generic
@@ -326,7 +327,9 @@ class CircuitSwapTerminations(generic.ObjectEditView):
 
         # Circuit must have at least one termination to swap
         if not circuit.termination_a and not circuit.termination_z:
-            messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
+            messages.error(request, _(
+                "No terminations have been defined for circuit {circuit}."
+            ).format(circuit=circuit))
             return redirect('circuits:circuit', pk=circuit.pk)
 
         return render(request, 'circuits/circuit_terminations_swap.html', {
@@ -374,7 +377,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
                 circuit.termination_z = None
                 circuit.save()
 
-            messages.success(request, f"Swapped terminations for circuit {circuit}.")
+            messages.success(request, _("Swapped terminations for circuit {circuit}.").format(circuit=circuit))
             return redirect('circuits:circuit', pk=circuit.pk)
 
         return render(request, 'circuits/circuit_terminations_swap.html', {

+ 17 - 13
netbox/core/views.py

@@ -76,7 +76,10 @@ class DataSourceSyncView(BaseObjectView):
         datasource = get_object_or_404(self.queryset, pk=pk)
         job = datasource.enqueue_sync_job(request)
 
-        messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
+        messages.success(
+            request,
+            _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource)
+        )
         return redirect(datasource.get_absolute_url())
 
 
@@ -235,7 +238,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
 
         candidate_config = get_object_or_404(ConfigRevision, pk=pk)
         candidate_config.activate()
-        messages.success(request, f"Restored configuration revision #{pk}")
+        messages.success(request, _("Restored configuration revision #{id}").format(id=pk))
 
         return redirect(candidate_config.get_absolute_url())
 
@@ -379,9 +382,9 @@ class BackgroundTaskDeleteView(BaseRQView):
             # Remove job id from queue and delete the actual job
             queue.connection.lrem(queue.key, 0, job.id)
             job.delete()
-            messages.success(request, f'Deleted job {job_id}')
+            messages.success(request, _('Job {id} has been deleted.').format(id=job_id))
         else:
-            messages.error(request, f'Error deleting job: {form.errors[0]}')
+            messages.error(request, _('Error deleting job {id}: {error}').format(id=job_id, error=form.errors[0]))
 
         return redirect(reverse('core:background_queue_list'))
 
@@ -394,13 +397,13 @@ class BackgroundTaskRequeueView(BaseRQView):
         try:
             job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
         except NoSuchJobError:
-            raise Http404(_("Job {job_id} not found").format(job_id=job_id))
+            raise Http404(_("Job {id} not found.").format(id=job_id))
 
         queue_index = QUEUES_MAP[job.origin]
         queue = get_queue_by_index(queue_index)
 
         requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
-        messages.success(request, f'You have successfully requeued: {job_id}')
+        messages.success(request, _('Job {id} has been re-enqueued.').format(id=job_id))
         return redirect(reverse('core:background_task', args=[job_id]))
 
 
@@ -412,7 +415,7 @@ class BackgroundTaskEnqueueView(BaseRQView):
         try:
             job = RQ_Job.fetch(job_id, connection=get_redis_connection(config['connection_config']),)
         except NoSuchJobError:
-            raise Http404(_("Job {job_id} not found").format(job_id=job_id))
+            raise Http404(_("Job {id} not found.").format(id=job_id))
 
         queue_index = QUEUES_MAP[job.origin]
         queue = get_queue_by_index(queue_index)
@@ -435,7 +438,7 @@ class BackgroundTaskEnqueueView(BaseRQView):
             registry = ScheduledJobRegistry(queue.name, queue.connection)
             registry.remove(job)
 
-        messages.success(request, f'You have successfully enqueued: {job_id}')
+        messages.success(request, _('Job {id} has been enqueued.').format(id=job_id))
         return redirect(reverse('core:background_task', args=[job_id]))
 
 
@@ -452,11 +455,11 @@ class BackgroundTaskStopView(BaseRQView):
         queue_index = QUEUES_MAP[job.origin]
         queue = get_queue_by_index(queue_index)
 
-        stopped, _ = stop_jobs(queue, job_id)
-        if len(stopped) == 1:
-            messages.success(request, f'You have successfully stopped {job_id}')
+        stopped_jobs = stop_jobs(queue, job_id)[0]
+        if len(stopped_jobs) == 1:
+            messages.success(request, _('Job {id} has been stopped.').format(id=job_id))
         else:
-            messages.error(request, f'Failed to stop {job_id}')
+            messages.error(request, _('Failed to stop job {id}').format(id=job_id))
 
         return redirect(reverse('core:background_task', args=[job_id]))
 
@@ -559,13 +562,14 @@ class SystemView(UserPassesTestMixin, View):
 
         # Raw data export
         if 'export' in request.GET:
+            params = [param.name for param in PARAMS]
             data = {
                 **stats,
                 'plugins': {
                     plugin.name: plugin.version for plugin in plugins
                 },
                 'config': {
-                    k: config.data[k] for k in sorted(config.data)
+                    k: getattr(config, k) for k in sorted(params)
                 },
             }
             response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')

+ 2 - 0
netbox/dcim/choices.py

@@ -886,6 +886,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_80211AD = 'ieee802.11ad'
     TYPE_80211AX = 'ieee802.11ax'
     TYPE_80211AY = 'ieee802.11ay'
+    TYPE_80211BE = 'ieee802.11be'
     TYPE_802151 = 'ieee802.15.1'
     TYPE_OTHER_WIRELESS = 'other-wireless'
 
@@ -1057,6 +1058,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_80211AD, 'IEEE 802.11ad'),
                 (TYPE_80211AX, 'IEEE 802.11ax'),
                 (TYPE_80211AY, 'IEEE 802.11ay'),
+                (TYPE_80211BE, 'IEEE 802.11be'),
                 (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
                 (TYPE_OTHER_WIRELESS, 'Other (Wireless)'),
             )

+ 1 - 0
netbox/dcim/constants.py

@@ -49,6 +49,7 @@ WIRELESS_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_80211AD,
     InterfaceTypeChoices.TYPE_80211AX,
     InterfaceTypeChoices.TYPE_80211AY,
+    InterfaceTypeChoices.TYPE_80211BE,
     InterfaceTypeChoices.TYPE_802151,
     InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
 ]

+ 27 - 22
netbox/dcim/forms/bulk_edit.py

@@ -1188,12 +1188,17 @@ class ComponentBulkEditForm(NetBoxModelBulkEditForm):
         required=False
     )
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+    def __init__(self, *args, initial=None, **kwargs):
+        try:
+            self.device_id = int(initial.get('device'))
+        except (TypeError, ValueError):
+            self.device_id = None
+
+        super().__init__(*args, initial=initial, **kwargs)
 
         # Limit module queryset to Modules which belong to the parent Device
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
+        if self.device_id:
+            device = Device.objects.filter(pk=self.device_id).first()
             self.fields['module'].queryset = Module.objects.filter(device=device)
         else:
             self.fields['module'].choices = ()
@@ -1201,8 +1206,8 @@ class ComponentBulkEditForm(NetBoxModelBulkEditForm):
 
 
 class ConsolePortBulkEditForm(
-    form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
@@ -1218,8 +1223,8 @@ class ConsolePortBulkEditForm(
 
 
 class ConsoleServerPortBulkEditForm(
-    form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
@@ -1235,8 +1240,8 @@ class ConsoleServerPortBulkEditForm(
 
 
 class PowerPortBulkEditForm(
-    form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
@@ -1253,8 +1258,8 @@ class PowerPortBulkEditForm(
 
 
 class PowerOutletBulkEditForm(
-    form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
@@ -1273,8 +1278,8 @@ class PowerOutletBulkEditForm(
         super().__init__(*args, **kwargs)
 
         # Limit power_port queryset to PowerPorts which belong to the parent Device
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
+        if self.device_id:
+            device = Device.objects.filter(pk=self.device_id).first()
             self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
         else:
             self.fields['power_port'].choices = ()
@@ -1282,12 +1287,12 @@ class PowerOutletBulkEditForm(
 
 
 class InterfaceBulkEditForm(
+    ComponentBulkEditForm,
     form_from_model(Interface, [
         'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
         'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
         'tx_power', 'wireless_lans'
-    ]),
-    ComponentBulkEditForm
+    ])
 ):
     enabled = forms.NullBooleanField(
         label=_('Enabled'),
@@ -1416,8 +1421,8 @@ class InterfaceBulkEditForm(
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
+        if self.device_id:
+            device = Device.objects.filter(pk=self.device_id).first()
 
             # Restrict parent/bridge/LAG interface assignment by device
             self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
@@ -1480,8 +1485,8 @@ class InterfaceBulkEditForm(
 
 
 class FrontPortBulkEditForm(
-    form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),
@@ -1497,8 +1502,8 @@ class FrontPortBulkEditForm(
 
 
 class RearPortBulkEditForm(
-    form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
-    ComponentBulkEditForm
+    ComponentBulkEditForm,
+    form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description'])
 ):
     mark_connected = forms.NullBooleanField(
         label=_('Mark connected'),

+ 6 - 3
netbox/dcim/forms/connections.py

@@ -19,7 +19,7 @@ def get_cable_form(a_type, b_type):
                 # Device component
                 if hasattr(term_cls, 'device'):
 
-                    attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
+                    attrs[f'termination_{cable_end}_device'] = DynamicModelMultipleChoiceField(
                         queryset=Device.objects.all(),
                         label=_('Device'),
                         required=False,
@@ -33,6 +33,7 @@ def get_cable_form(a_type, b_type):
                         label=term_cls._meta.verbose_name.title(),
                         context={
                             'disabled': '_occupied',
+                            'parent': 'device',
                         },
                         query_params={
                             'device_id': f'$termination_{cable_end}_device',
@@ -43,7 +44,7 @@ def get_cable_form(a_type, b_type):
                 # PowerFeed
                 elif term_cls == PowerFeed:
 
-                    attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
+                    attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelMultipleChoiceField(
                         queryset=PowerPanel.objects.all(),
                         label=_('Power Panel'),
                         required=False,
@@ -57,6 +58,7 @@ def get_cable_form(a_type, b_type):
                         label=_('Power Feed'),
                         context={
                             'disabled': '_occupied',
+                            'parent': 'powerpanel',
                         },
                         query_params={
                             'power_panel_id': f'$termination_{cable_end}_powerpanel',
@@ -66,7 +68,7 @@ def get_cable_form(a_type, b_type):
                 # CircuitTermination
                 elif term_cls == CircuitTermination:
 
-                    attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
+                    attrs[f'termination_{cable_end}_circuit'] = DynamicModelMultipleChoiceField(
                         queryset=Circuit.objects.all(),
                         label=_('Circuit'),
                         selector=True,
@@ -79,6 +81,7 @@ def get_cable_form(a_type, b_type):
                         label=_('Side'),
                         context={
                             'disabled': '_occupied',
+                            'parent': 'circuit',
                         },
                         query_params={
                             'circuit_id': f'$termination_{cable_end}_circuit',

+ 24 - 6
netbox/dcim/views.py

@@ -2059,7 +2059,7 @@ class DeviceRenderConfigView(generic.ObjectView):
             try:
                 rendered_config = config_template.render(context=context_data)
             except TemplateError as e:
-                messages.error(request, f"An error occurred while rendering the template: {e}")
+                messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
                 rendered_config = traceback.format_exc()
 
         return {
@@ -2823,7 +2823,13 @@ class DeviceBayPopulateView(generic.ObjectEditView):
             device_bay.snapshot()
             device_bay.installed_device = form.cleaned_data['installed_device']
             device_bay.save()
-            messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
+            messages.success(
+                request,
+                _("Installed device {device} in bay {device_bay}.").format(
+                    device=device_bay.installed_device,
+                    device_bay=device_bay
+                )
+            )
             return_url = self.get_return_url(request)
 
             return redirect(return_url)
@@ -2858,7 +2864,13 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
             removed_device = device_bay.installed_device
             device_bay.installed_device = None
             device_bay.save()
-            messages.success(request, f"{removed_device} has been removed from {device_bay}.")
+            messages.success(
+                request,
+                _("Removed device {device} from bay {device_bay}.").format(
+                    device=removed_device,
+                    device_bay=device_bay
+                )
+            )
             return_url = self.get_return_url(request, device_bay.device)
 
             return redirect(return_url)
@@ -3426,7 +3438,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
 
                 membership_form.save()
                 messages.success(request, mark_safe(
-                    f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
+                    _('Added member <a href="{url}">{escape(device)}</a>').format(url=device.get_absolute_url())
                 ))
 
                 if '_addanother' in request.POST:
@@ -3471,7 +3483,10 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         # Protect master device from being removed
         virtual_chassis = VirtualChassis.objects.filter(master=device).first()
         if virtual_chassis is not None:
-            messages.error(request, f'Unable to remove master device {device} from the virtual chassis.')
+            messages.error(
+                request,
+                _('Unable to remove master device {device} from the virtual chassis.').format(device=device)
+            )
             return redirect(device.get_absolute_url())
 
         if form.is_valid():
@@ -3483,7 +3498,10 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
                 device.vc_priority = None
                 device.save()
 
-            msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
+            msg = _('Removed {device} from virtual chassis {chassis}').format(
+                device=device,
+                chassis=device.virtual_chassis
+            )
             messages.success(request, msg)
 
             return redirect(self.get_return_url(request, device))

+ 2 - 4
netbox/extras/models/customfields.py

@@ -352,13 +352,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
         if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
             if not self.related_object_type:
                 raise ValidationError({
-                    'object_type': _("Object fields must define an object type.")
+                    'related_object_type': _("Object fields must define an object type.")
                 })
         elif self.related_object_type:
             raise ValidationError({
-                'object_type': _(
-                    "{type} fields may not define an object type.")
-                .format(type=self.get_type_display())
+                'type': _("{type} fields may not define an object type.") .format(type=self.get_type_display())
             })
 
     def serialize(self, value):

+ 15 - 1
netbox/ipam/forms/bulk_edit.py

@@ -221,6 +221,19 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
             'group_id': '$site_group',
         }
     )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label=_('VLAN Group')
+    )
+    vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        label=_('VLAN'),
+        query_params={
+            'group_id': '$vlan_group',
+        }
+    )
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
@@ -269,9 +282,10 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
         FieldSet('tenant', 'status', 'role', 'description'),
         FieldSet('region', 'site_group', 'site', name=_('Site')),
         FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')),
+        FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')),
     )
     nullable_fields = (
-        'site', 'vrf', 'tenant', 'role', 'description', 'comments',
+        'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments',
     )
 
 

+ 1 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from utilities.string import trailing_slash
 # Environment setup
 #
 
-VERSION = '4.0.8'
+VERSION = '4.0.9'
 HOSTNAME = platform.node()
 # Set the base directory two levels up
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

+ 2 - 1
netbox/netbox/tables/tables.py

@@ -6,6 +6,7 @@ from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
+from django.db.models.fields.reverse_related import ManyToOneRel
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 from django.utils.safestring import mark_safe
@@ -102,7 +103,7 @@ class BaseTable(tables.Table):
                             field = model._meta.get_field(field_name)
                         except FieldDoesNotExist:
                             break
-                        if isinstance(field, RelatedField):
+                        if isinstance(field, (RelatedField, ManyToOneRel)):
                             # Follow ForeignKeys to the related model
                             prefetch_path.append(field_name)
                             model = field.remote_field.model

+ 26 - 0
netbox/netbox/tests/test_import.py

@@ -2,6 +2,7 @@ from django.test import override_settings
 
 from core.models import ObjectType
 from dcim.models import *
+from extras.models import CustomField
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import ObjectPermission
 from utilities.testing import ModelViewTestCase, create_tags
@@ -116,3 +117,28 @@ class CSVImportTestCase(ModelViewTestCase):
         # Test POST with permission
         self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
         self.assertEqual(Region.objects.count(), 0)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_custom_field_defaults(self):
+        self.add_permissions('dcim.add_region')
+        csv_data = [
+            'name,slug,description',
+            'Region 1,region-1,abc',
+        ]
+        data = {
+            'format': ImportFormatChoices.CSV,
+            'data': self._get_csv_data(csv_data),
+            'csv_delimiter': CSVDelimiterChoices.AUTO,
+        }
+
+        cf = CustomField.objects.create(
+            name='tcf',
+            type='text',
+            required=False,
+            default='def-cf-text'
+        )
+        cf.object_types.set([ObjectType.objects.get_for_model(self.model)])
+
+        self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+        region = Region.objects.get(slug='region-1')
+        self.assertEqual(region.cf['tcf'], 'def-cf-text')

+ 44 - 8
netbox/netbox/views/generic/bulk_views.py

@@ -4,6 +4,7 @@ from copy import deepcopy
 
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericRel
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
@@ -17,7 +18,8 @@ from django.utils.translation import gettext as _
 from django_tables2.export import TableExport
 
 from core.models import ObjectType
-from extras.models import ExportTemplate
+from extras.choices import CustomFieldUIEditableChoices
+from extras.models import CustomField, ExportTemplate
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
 from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
@@ -106,7 +108,13 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
         try:
             return template.render_to_response(self.queryset)
         except Exception as e:
-            messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
+            messages.error(
+                request,
+                _("There was an error rendering the selected export template ({template}): {error}").format(
+                    template=template.name,
+                    error=e
+                )
+            )
             # Strip the `export` param and redirect user to the filtered objects list
             query_params = request.GET.copy()
             query_params.pop('export')
@@ -409,6 +417,17 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 if instance.pk and hasattr(instance, 'snapshot'):
                     instance.snapshot()
 
+            else:
+                # For newly created objects, apply any default custom field values
+                custom_fields = CustomField.objects.filter(
+                    object_types=ContentType.objects.get_for_model(self.queryset.model),
+                    ui_editable=CustomFieldUIEditableChoices.YES
+                )
+                for cf in custom_fields:
+                    field_name = f'cf_{cf.name}'
+                    if field_name not in record:
+                        record[field_name] = cf.default
+
             # Instantiate the model form for the object
             model_form_kwargs = {
                 'data': record,
@@ -668,7 +687,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
         # Retrieve objects being edited
         table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
         if not table.rows:
-            messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
+            messages.warning(
+                request,
+                _("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural)
+            )
             return redirect(self.get_return_url(request))
 
         return render(request, self.template_name, {
@@ -745,8 +767,13 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
                                 raise PermissionsViolation
 
-                            model_name = self.queryset.model._meta.verbose_name_plural
-                            messages.success(request, f"Renamed {len(selected_objects)} {model_name}")
+                            messages.success(
+                                request,
+                                _("Renamed {count} {object_type}").format(
+                                    count=len(selected_objects),
+                                    object_type=self.queryset.model._meta.verbose_name_plural
+                                )
+                            )
                             return redirect(self.get_return_url(request))
 
                 except (AbortRequest, PermissionsViolation) as e:
@@ -838,7 +865,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
                     messages.error(request, mark_safe(e.message))
                     return redirect(self.get_return_url(request))
 
-                msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
+                msg = _("Deleted {count} {object_type}").format(
+                    count=deleted_count,
+                    object_type=model._meta.verbose_name_plural
+                )
                 logger.info(msg)
                 messages.success(request, msg)
                 return redirect(self.get_return_url(request))
@@ -855,7 +885,10 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
         # Retrieve objects being deleted
         table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
         if not table.rows:
-            messages.warning(request, "No {} were selected for deletion.".format(model._meta.verbose_name_plural))
+            messages.warning(
+                request,
+                _("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural)
+            )
             return redirect(self.get_return_url(request))
 
         return render(request, self.template_name, {
@@ -900,7 +933,10 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
         selected_objects = self.parent_model.objects.filter(pk__in=pk_list)
         if not selected_objects:
-            messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
+            messages.warning(
+                request,
+                _("No {object_type} were selected.").format(object_type=self.parent_model._meta.verbose_name_plural)
+            )
             return redirect(self.get_return_url(request))
         table = self.table(selected_objects, orderable=False)
 

+ 9 - 4
netbox/netbox/views/generic/feature_views.py

@@ -202,11 +202,14 @@ class ObjectSyncDataView(View):
         obj = get_object_or_404(qs, **kwargs)
 
         if not obj.data_file:
-            messages.error(request, f"Unable to synchronize data: No data file set.")
+            messages.error(request, _("Unable to synchronize data: No data file set."))
             return redirect(obj.get_absolute_url())
 
         obj.sync(save=True)
-        messages.success(request, f"Synchronized data for {model._meta.verbose_name} {obj}.")
+        messages.success(request, _("Synchronized data for {object_type} {object}.").format(
+            object_type=model._meta.verbose_name,
+            object=obj
+        ))
 
         return redirect(obj.get_absolute_url())
 
@@ -228,7 +231,9 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
             for obj in selected_objects:
                 obj.sync(save=True)
 
-            model_name = self.queryset.model._meta.verbose_name_plural
-            messages.success(request, f"Synced {len(selected_objects)} {model_name}")
+            messages.success(request, _("Synced {count} {object_type}").format(
+                count=len(selected_objects),
+                object_type=self.queryset.model._meta.verbose_name_plural
+            ))
 
         return redirect(self.get_return_url(request))

Разница между файлами не показана из-за своего большого размера
+ 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


+ 3 - 1
netbox/project-static/src/messages.ts

@@ -10,7 +10,9 @@ export function initMessages(): void {
   for (const element of elements) {
     if (element !== null) {
       const toast = new Toast(element);
-      toast.show();
+      if (!toast.isShown()) {
+        toast.show();
+      }
     }
   }
 }

+ 4 - 0
netbox/project-static/styles/overrides/_tabler.scss

@@ -44,3 +44,7 @@ table a {
 [data-bs-theme=dark] ::selection {
   background-color: rgba(var(--tblr-primary-rgb),.48)
 }
+
+pre code {
+  padding: unset;
+}

+ 15 - 8
netbox/project-static/yarn.lock

@@ -867,13 +867,20 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^3.0.2, braces@~3.0.2:
+braces@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
   dependencies:
     fill-range "^7.0.1"
 
+braces@~3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+  dependencies:
+    fill-range "^7.1.1"
+
 call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
@@ -1520,10 +1527,10 @@ file-entry-cache@^6.0.1:
   dependencies:
     flat-cache "^3.0.4"
 
-fill-range@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
-  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+fill-range@^7.0.1, fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
   dependencies:
     to-regex-range "^5.0.1"
 
@@ -1816,9 +1823,9 @@ ignore@^5.2.0:
   integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
 
 immutable@^4.0.0:
-  version "4.3.6"
-  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
-  integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
+  version "4.3.7"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
+  integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
 
 import-fresh@^3.2.1:
   version "3.3.0"

+ 6 - 1
netbox/templates/core/rq_worker_list.html

@@ -24,7 +24,12 @@
   </div>
 {% endblock page-header %}
 
-{% block title %}{{ status|capfirst }} {% trans "Workers in " %}{{ queue.name }}{% endblock %}
+{% block title %}
+  {{ status|capfirst }}
+  {% blocktrans trimmed with queue_name=queue.name %}
+    Workers in {{ queue_name }}
+  {% endblocktrans %}
+{% endblock %}
 
 {% block controls %}{% endblock %}
 

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 2 - 1
netbox/utilities/filters.py

@@ -3,8 +3,8 @@ from django import forms
 from django.conf import settings
 from django.core.exceptions import ValidationError
 from django_filters.constants import EMPTY_VALUES
-from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 
 __all__ = (
     'ContentTypeFilter',
@@ -116,6 +116,7 @@ class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
     field_class = multivalue_field_factory(forms.CharField)
 
 
+@extend_schema_field(OpenApiTypes.STR)
 class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
     """
     Filters for a set of Models, including all descendant models within a Tree.  Example: [<Region: R1>,<Region: R2>]

+ 7 - 5
netbox/virtualization/views.py

@@ -271,8 +271,9 @@ class ClusterAddDevicesView(generic.ObjectEditView):
                     device.cluster = cluster
                     device.save()
 
-            messages.success(request, "Added {} devices to cluster {}".format(
-                len(device_pks), cluster
+            messages.success(request, _("Added {count} devices to cluster {cluster}").format(
+                count=len(device_pks),
+                cluster=cluster
             ))
             return redirect(cluster.get_absolute_url())
 
@@ -305,8 +306,9 @@ class ClusterRemoveDevicesView(generic.ObjectEditView):
                         device.cluster = None
                         device.save()
 
-                messages.success(request, "Removed {} devices from cluster {}".format(
-                    len(device_pks), cluster
+                messages.success(request, _("Removed {count} devices from cluster {cluster}").format(
+                    count=len(device_pks),
+                    cluster=cluster
                 ))
                 return redirect(cluster.get_absolute_url())
 
@@ -444,7 +446,7 @@ class VirtualMachineRenderConfigView(generic.ObjectView):
             try:
                 rendered_config = config_template.render(context=context_data)
             except TemplateError as e:
-                messages.error(request, f"An error occurred while rendering the template: {e}")
+                messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
                 rendered_config = traceback.format_exc()
 
         return {

+ 6 - 6
requirements.txt

@@ -1,14 +1,14 @@
-Django==5.0.7
+Django==5.0.8
 django-cors-headers==4.4.0
-django-debug-toolbar==4.3.0
+django-debug-toolbar==4.4.6
 django-filter==24.2
-django-htmx==1.18.0
+django-htmx==1.19.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.16.0
 django-pglocks==1.0.4
 django-prometheus==2.3.1
 django-redis==5.4.0
-django-rich==1.9.0
+django-rich==1.10.0
 django-rq==2.10.2
 django-taggit==5.0.1
 django-tables2==2.7.0
@@ -17,7 +17,7 @@ djangorestframework==3.15.2
 drf-spectacular==0.27.2
 drf-spectacular-sidecar==2024.7.1
 feedparser==6.0.11
-gunicorn==22.0.0
+gunicorn==23.0.0
 Jinja2==3.1.4
 Markdown==3.6
 mkdocs-material==9.5.30
@@ -26,7 +26,7 @@ netaddr==1.3.0
 nh3==0.2.18
 Pillow==10.4.0
 psycopg[c,pool]==3.2.1
-PyYAML==6.0.1
+PyYAML==6.0.2
 requests==2.32.3
 social-auth-app-django==5.4.2
 social-auth-core==4.5.4

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