Explorar o código

Merge pull request #16429 from netbox-community/develop

Release v4.0.5
Jeremy Stretch hai 1 ano
pai
achega
5530556626
Modificáronse 60 ficheiros con 9985 adicións e 8064 borrados
  1. 2 2
      .github/workflows/auto-assign-issue.yml
  2. 15 4
      .github/workflows/ci.yml
  3. 1 1
      .github/workflows/close-stale-issues.yml
  4. 45 0
      .github/workflows/update-translation-strings.yml
  5. 1 0
      .gitignore
  6. 0 6
      docs/customization/custom-scripts.md
  7. 1 9
      docs/development/release-checklist.md
  8. 26 5
      docs/development/translations.md
  9. BIN=BIN
      docs/media/development/transifex_download.png
  10. BIN=BIN
      docs/media/development/transifex_pull_request.png
  11. BIN=BIN
      docs/media/development/transifex_sync.png
  12. 2 2
      docs/plugins/development/forms.md
  13. 16 0
      docs/release-notes/version-4.0.md
  14. 1 0
      netbox/circuits/search.py
  15. 1 1
      netbox/core/views.py
  16. 2 0
      netbox/dcim/choices.py
  17. 5 5
      netbox/dcim/models/cables.py
  18. 14 32
      netbox/dcim/tables/devices.py
  19. 10 0
      netbox/extras/api/serializers_/change_logging.py
  20. 4 3
      netbox/extras/context_managers.py
  21. 1 0
      netbox/extras/dashboard/widgets.py
  22. 19 13
      netbox/extras/events.py
  23. 4 10
      netbox/extras/forms/filtersets.py
  24. 71 1
      netbox/extras/models/change_logging.py
  25. 6 0
      netbox/extras/models/staging.py
  26. 8 21
      netbox/extras/signals.py
  27. 28 0
      netbox/extras/tests/test_changelog.py
  28. 25 2
      netbox/extras/tests/test_event_rules.py
  29. 3 3
      netbox/extras/views.py
  30. 2 1
      netbox/ipam/models/services.py
  31. 1 1
      netbox/ipam/tests/test_api.py
  32. 5 0
      netbox/ipam/views.py
  33. 1 1
      netbox/netbox/context.py
  34. 3 2
      netbox/netbox/graphql/filter_mixins.py
  35. 1 1
      netbox/netbox/settings.py
  36. 0 0
      netbox/project-static/dist/netbox.css
  37. 0 0
      netbox/project-static/dist/netbox.js
  38. 0 0
      netbox/project-static/dist/netbox.js.map
  39. 56 20
      netbox/project-static/src/search.ts
  40. 2 2
      netbox/project-static/styles/custom/_code.scss
  41. 12 0
      netbox/project-static/styles/overrides/_tabler.scss
  42. 1 1
      netbox/templates/core/object_jobs.html
  43. 2 1
      netbox/templates/dcim/device.html
  44. 2 1
      netbox/templates/dcim/site.html
  45. 19 5
      netbox/templates/extras/objectchange.html
  46. 2 2
      netbox/templates/extras/script/base.html
  47. 3 3
      netbox/templates/extras/script_list.html
  48. 1 1
      netbox/templates/virtualization/cluster.html
  49. 1 1
      netbox/templates/virtualization/virtualmachine.html
  50. 1242 1009
      netbox/translations/de/LC_MESSAGES/django.po
  51. 3344 2855
      netbox/translations/en/LC_MESSAGES/django.po
  52. 1236 1003
      netbox/translations/fr/LC_MESSAGES/django.po
  53. 1236 1003
      netbox/translations/pt/LC_MESSAGES/django.po
  54. 1236 1003
      netbox/translations/ru/LC_MESSAGES/django.po
  55. 1236 1003
      netbox/translations/tr/LC_MESSAGES/django.po
  56. 3 10
      netbox/utilities/serialization.py
  57. 1 1
      netbox/utilities/templates/buttons/export.html
  58. 14 12
      netbox/utilities/templatetags/helpers.py
  59. 10 2
      netbox/utilities/testing/api.py
  60. 2 0
      netbox/virtualization/tables/virtualmachines.py

+ 2 - 2
.github/workflows/auto-assign-issue.yml

@@ -12,10 +12,10 @@ jobs:
   auto-assign:
   auto-assign:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: pozil/auto-assign-issue@v1
+      - uses: pozil/auto-assign-issue@v2
         if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
         if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
         with:
         with:
           # Weighted assignments
           # Weighted assignments
-          assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
+          assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
           numOfAssignee: 1
           numOfAssignee: 1
           abortIfPreviousAssignees: true
           abortIfPreviousAssignees: true

+ 15 - 4
.github/workflows/ci.yml

@@ -1,7 +1,18 @@
 name: CI
 name: CI
-on: [push, pull_request]
+
+on:
+  push:
+    paths-ignore:
+      - 'contrib/**'
+      - 'docs/**'
+  pull_request:
+    paths-ignore:
+      - 'contrib/**'
+      - 'docs/**'
+
 permissions:
 permissions:
   contents: read
   contents: read
+
 jobs:
 jobs:
   build:
   build:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -34,12 +45,12 @@ jobs:
       uses: actions/checkout@v4
       uses: actions/checkout@v4
 
 
     - name: Set up Python ${{ matrix.python-version }}
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v4
+      uses: actions/setup-python@v5
       with:
       with:
         python-version: ${{ matrix.python-version }}
         python-version: ${{ matrix.python-version }}
 
 
     - name: Use Node.js ${{ matrix.node-version }}
     - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v3
+      uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     
     
@@ -47,7 +58,7 @@ jobs:
       run: npm install -g yarn
       run: npm install -g yarn
     
     
     - name: Setup Node.js with Yarn Caching
     - name: Setup Node.js with Yarn Caching
-      uses: actions/setup-node@v3
+      uses: actions/setup-node@v4
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: yarn
         cache: yarn

+ 1 - 1
.github/workflows/close-stale-issues.yml

@@ -29,7 +29,7 @@ jobs:
             necessary.
             necessary.
           days-before-issue-stale: 90
           days-before-issue-stale: 90
           days-before-issue-close: 30
           days-before-issue-close: 30
-          exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
+          exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
           stale-issue-label: 'pending closure'
           stale-issue-label: 'pending closure'
           stale-issue-message: >
           stale-issue-message: >
             This issue has been automatically marked as stale because it has not had
             This issue has been automatically marked as stale because it has not had

+ 45 - 0
.github/workflows/update-translation-strings.yml

@@ -0,0 +1,45 @@
+name: Update translation strings
+
+on:
+  schedule:
+    - cron: '0 5 * * *'
+  workflow_dispatch:
+
+permissions:
+  contents: write
+
+env:
+  LOCALE: "en"
+
+jobs:
+  makemessages:
+    runs-on: ubuntu-latest
+    env:
+      NETBOX_CONFIGURATION: netbox.configuration_testing
+
+    steps:
+    - name: Check out repo
+      uses: actions/checkout@v4
+
+    - name: Set up Python
+      uses: actions/setup-python@v5
+      with:
+        python-version: 3.11
+
+    - name: Install system dependencies
+      run: sudo apt install -y gettext
+
+    - name: Install Python dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r requirements.txt
+
+    - name: Run makemessages
+      run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
+
+    - name: Commit changes
+      uses: EndBug/add-and-commit@v9
+      with:
+        add: 'netbox/translations/'
+        default_author: github_actions
+        message: 'Update source translation strings'

+ 1 - 0
.gitignore

@@ -21,6 +21,7 @@ local_settings.py
 !upgrade.sh
 !upgrade.sh
 fabfile.py
 fabfile.py
 gunicorn.py
 gunicorn.py
+uwsgi.ini
 netbox.log
 netbox.log
 netbox.pid
 netbox.pid
 .DS_Store
 .DS_Store

+ 0 - 6
docs/customization/custom-scripts.md

@@ -65,12 +65,6 @@ class AnotherCustomScript(Script):
 script_order = (MyCustomScript, AnotherCustomScript)
 script_order = (MyCustomScript, AnotherCustomScript)
 ```
 ```
 
 
-## Module Attributes
-
-### `name`
-
-You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the module's file name will be used.
-
 ## Script Attributes
 ## Script Attributes
 
 
 Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
 Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.

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

@@ -86,15 +86,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
 
 
 ### Update & Compile Translations
 ### Update & Compile Translations
 
 
-Log into [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) to download the updated string maps. Download the resource (portable object, or `.po`) file for each language and save them to `netbox/translations/$lang/LC_MESSAGES/django.po`, overwriting the current files. (Be sure to click the **Download for use** link.)
-
-![Transifex download](../media/development/transifex_download.png)
-
-Once the resource files for all languages have been updated, compile the machine object (`.mo`) files using the `compilemessages` management command:
-
-```nohighlight
-./manage.py compilemessages
-```
+Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this.
 
 
 ### Update Version and Changelog
 ### Update Version and Changelog
 
 

+ 26 - 5
docs/development/translations.md

@@ -6,17 +6,38 @@ All language translations in NetBox are generated from the source file found at
 
 
 Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
 Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
 
 
-Immediately prior to each NetBox release, the translation maps for all completed languages will be downloaded from Transifex, compiled, and checked into the NetBox code base by a maintainer.
-
 ## Updating Translation Sources
 ## Updating Translation Sources
 
 
-To update the English `.po` file from which all translations are derived, use the `makemessages` management command:
+To update the English `.po` file from which all translations are derived, use the `makemessages` management command (ignoring the `project-static/` directory):
+
+```nohighlight
+./manage.py makemessages -l en -i "project-static/*"
+```
+
+Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
+
+## Updating Translated Strings
+
+Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
+
+To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
+
+![Transifex manual sync](../media/development/transifex_sync.png)
+
+Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files.
+
+!!! tip
+    The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added.
+
+![Transifex pull request](../media/development/transifex_pull_request.png)
+
+Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command:
 
 
 ```nohighlight
 ```nohighlight
-./manage.py makemessages -l en
+./manage.py compilemessages
 ```
 ```
 
 
-Then, commit the change and push to the `develop` branch on GitHub. After some time, any new strings will appear for translation on Transifex automatically.
+Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
 
 
 ## Proposing New Languages
 ## Proposing New Languages
 
 

BIN=BIN
docs/media/development/transifex_download.png


BIN=BIN
docs/media/development/transifex_pull_request.png


BIN=BIN
docs/media/development/transifex_sync.png


+ 2 - 2
docs/plugins/development/forms.md

@@ -89,13 +89,13 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from dcim.models import Site
 from dcim.models import Site
-from netbox.forms import NetBoxModelImportForm
+from netbox.forms import NetBoxModelBulkEditForm
 from utilities.forms import CommentField, DynamicModelChoiceField
 from utilities.forms import CommentField, DynamicModelChoiceField
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from .models import MyModel, MyModelStatusChoices
 from .models import MyModel, MyModelStatusChoices
 
 
 
 
-class MyModelEditForm(NetBoxModelImportForm):
+class MyModelBulkEditForm(NetBoxModelBulkEditForm):
     name = forms.CharField(
     name = forms.CharField(
         required=False
         required=False
     )
     )

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

@@ -1,5 +1,21 @@
 # NetBox v4.0
 # NetBox v4.0
 
 
+## v4.0.4 (FUTURE)
+
+### Enhancements
+
+* [#14810](https://github.com/netbox-community/netbox/issues/14810) - Enable contact assignment for services
+* [#15489](https://github.com/netbox-community/netbox/issues/15489) - Add 1000Base-TX interface type
+* [#16290](https://github.com/netbox-community/netbox/issues/16290) - Capture entire object in changelog data (but continue to display only non-internal attributes)
+
+### Bug Fixes
+
+* [#13422](https://github.com/netbox-community/netbox/issues/13422) - Rebuild MPTT trees for applicable models after merging staged changes
+* [#16202](https://github.com/netbox-community/netbox/issues/16202) - Fix site map button URL for certain localizations
+* [#16286](https://github.com/netbox-community/netbox/issues/16286) - Fix global search support for provider accounts
+
+---
+
 ## v4.0.3 (2024-05-22)
 ## v4.0.3 (2024-05-22)
 
 
 ### Enhancements
 ### Enhancements

+ 1 - 0
netbox/circuits/search.py

@@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex):
     display_attrs = ('description',)
     display_attrs = ('description',)
 
 
 
 
+@register_search
 class ProviderAccountIndex(SearchIndex):
 class ProviderAccountIndex(SearchIndex):
     model = models.ProviderAccount
     model = models.ProviderAccount
     fields = (
     fields = (

+ 1 - 1
netbox/core/views.py

@@ -224,7 +224,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
         for param in PARAMS:
         for param in PARAMS:
             params.append((
             params.append((
                 param.name,
                 param.name,
-                current_config.data.get(param.name, None),
+                current_config.data.get(param.name, None) if current_config else None,
                 candidate_config.data.get(param.name, None)
                 candidate_config.data.get(param.name, None)
             ))
             ))
 
 

+ 2 - 0
netbox/dcim/choices.py

@@ -828,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100ME_FIXED = '100base-tx'
     TYPE_100ME_FIXED = '100base-tx'
     TYPE_100ME_T1 = '100base-t1'
     TYPE_100ME_T1 = '100base-t1'
     TYPE_1GE_FIXED = '1000base-t'
     TYPE_1GE_FIXED = '1000base-t'
+    TYPE_1GE_TX_FIXED = '1000base-tx'
     TYPE_1GE_GBIC = '1000base-x-gbic'
     TYPE_1GE_GBIC = '1000base-x-gbic'
     TYPE_1GE_SFP = '1000base-x-sfp'
     TYPE_1GE_SFP = '1000base-x-sfp'
     TYPE_2GE_FIXED = '2.5gbase-t'
     TYPE_2GE_FIXED = '2.5gbase-t'
@@ -987,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
                 (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
                 (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
                 (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
                 (TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
                 (TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
+                (TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
                 (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
                 (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
                 (TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
                 (TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
                 (TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
                 (TYPE_10GE_FIXED, '10GBASE-T (10GE)'),

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

@@ -355,11 +355,11 @@ class CableTermination(ChangeLoggedModel):
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
         # Set the cable on the terminating object
         # Set the cable on the terminating object
-        termination_model = self.termination._meta.model
-        termination_model.objects.filter(pk=self.termination_id).update(
-            cable=self.cable,
-            cable_end=self.cable_end
-        )
+        termination = self.termination._meta.model.objects.get(pk=self.termination_id)
+        termination.snapshot()
+        termination.cable = self.cable
+        termination.cable_end = self.cable_end
+        termination.save()
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
 
 

+ 14 - 32
netbox/dcim/tables/devices.py

@@ -43,14 +43,6 @@ MODULEBAY_STATUS = """
 """
 """
 
 
 
 
-def get_cabletermination_row_class(record):
-    if record.mark_connected:
-        return 'success'
-    elif record.cable:
-        return record.cable.get_status_color()
-    return ''
-
-
 #
 #
 # Device roles
 # Device roles
 #
 #
@@ -339,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
         verbose_name=_('Mark Connected'),
         verbose_name=_('Mark Connected'),
     )
     )
 
 
+    class Meta:
+        row_attrs = {
+            'data-name': lambda record: record.name,
+            'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
+            'data-cable-status': lambda record: record.cable.status if record.cable else "",
+            'data-type': lambda record: record.type
+        }
+
     def value_link_peer(self, value):
     def value_link_peer(self, value):
         return ', '.join([
         return ', '.join([
             f"{termination.parent_object} > {termination}" for termination in value
             f"{termination.parent_object} > {termination}" for termination in value
@@ -386,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
         extra_buttons=CONSOLEPORT_BUTTONS
         extra_buttons=CONSOLEPORT_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.ConsolePort
         model = models.ConsolePort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
             'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
             'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
 class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -431,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
         extra_buttons=CONSOLESERVERPORT_BUTTONS
         extra_buttons=CONSOLESERVERPORT_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.ConsoleServerPort
         model = models.ConsoleServerPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
             'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
             'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
         default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
 class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -483,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
         extra_buttons=POWERPORT_BUTTONS
         extra_buttons=POWERPORT_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.PowerPort
         model = models.PowerPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
@@ -492,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
         )
         )
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
 class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
@@ -534,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
         extra_buttons=POWEROUTLET_BUTTONS
         extra_buttons=POWEROUTLET_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.PowerOutlet
         model = models.PowerOutlet
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
@@ -543,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
         )
         )
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class BaseInterfaceTable(NetBoxTable):
 class BaseInterfaceTable(NetBoxTable):
@@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
         extra_buttons=FRONTPORT_BUTTONS
         extra_buttons=FRONTPORT_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.FrontPort
         model = models.FrontPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
@@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
             'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
         )
         )
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
 class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
@@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
         extra_buttons=REARPORT_BUTTONS
         extra_buttons=REARPORT_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.RearPort
         model = models.RearPort
         fields = (
         fields = (
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
             'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
@@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
             'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
         )
         )
-        row_attrs = {
-            'class': get_cabletermination_row_class
-        }
 
 
 
 
 class DeviceBayTable(DeviceComponentTable):
 class DeviceBayTable(DeviceComponentTable):

+ 10 - 0
netbox/extras/api/serializers_/change_logging.py

@@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
     changed_object = serializers.SerializerMethodField(
     changed_object = serializers.SerializerMethodField(
         read_only=True
         read_only=True
     )
     )
+    prechange_data = serializers.JSONField(
+        source='prechange_data_clean',
+        read_only=True,
+        allow_null=True
+    )
+    postchange_data = serializers.JSONField(
+        source='postchange_data_clean',
+        read_only=True,
+        allow_null=True
+    )
 
 
     class Meta:
     class Meta:
         model = ObjectChange
         model = ObjectChange

+ 4 - 3
netbox/extras/context_managers.py

@@ -13,13 +13,14 @@ def event_tracking(request):
     :param request: WSGIRequest object with a unique `id` set
     :param request: WSGIRequest object with a unique `id` set
     """
     """
     current_request.set(request)
     current_request.set(request)
-    events_queue.set([])
+    events_queue.set({})
 
 
     yield
     yield
 
 
     # Flush queued webhooks to RQ
     # Flush queued webhooks to RQ
-    flush_events(events_queue.get())
+    if events := list(events_queue.get().values()):
+        flush_events(events)
 
 
     # Clear context vars
     # Clear context vars
     current_request.set(None)
     current_request.set(None)
-    events_queue.set([])
+    events_queue.set({})

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

@@ -265,6 +265,7 @@ class ObjectListWidget(DashboardWidget):
         parameters = self.config.get('url_params') or {}
         parameters = self.config.get('url_params') or {}
         if page_size := self.config.get('page_size'):
         if page_size := self.config.get('page_size'):
             parameters['per_page'] = page_size
             parameters['per_page'] = page_size
+        parameters['embedded'] = True
 
 
         if parameters:
         if parameters:
             try:
             try:

+ 19 - 13
netbox/extras/events.py

@@ -58,15 +58,21 @@ def enqueue_object(queue, instance, user, request_id, action):
     if model_name not in registry['model_features']['event_rules'].get(app_label, []):
     if model_name not in registry['model_features']['event_rules'].get(app_label, []):
         return
         return
 
 
-    queue.append({
-        'content_type': ContentType.objects.get_for_model(instance),
-        'object_id': instance.pk,
-        'event': action,
-        'data': serialize_for_event(instance),
-        'snapshots': get_snapshots(instance, action),
-        'username': user.username,
-        'request_id': request_id
-    })
+    assert instance.pk is not None
+    key = f'{app_label}.{model_name}:{instance.pk}'
+    if key in queue:
+        queue[key]['data'] = serialize_for_event(instance)
+        queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
+    else:
+        queue[key] = {
+            'content_type': ContentType.objects.get_for_model(instance),
+            'object_id': instance.pk,
+            'event': action,
+            'data': serialize_for_event(instance),
+            'snapshots': get_snapshots(instance, action),
+            'username': user.username,
+            'request_id': request_id
+        }
 
 
 
 
 def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
 def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
@@ -163,14 +169,14 @@ def process_event_queue(events):
         )
         )
 
 
 
 
-def flush_events(queue):
+def flush_events(events):
     """
     """
-    Flush a list of object representation to RQ for webhook processing.
+    Flush a list of object representations to RQ for event processing.
     """
     """
-    if queue:
+    if events:
         for name in settings.EVENTS_PIPELINE:
         for name in settings.EVENTS_PIPELINE:
             try:
             try:
                 func = import_string(name)
                 func = import_string(name)
-                func(queue)
+                func(events)
             except Exception as e:
             except Exception as e:
                 logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
                 logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

+ 4 - 10
netbox/extras/forms/filtersets.py

@@ -464,13 +464,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('User')
         label=_('User')
     )
     )
-    assigned_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ObjectType.objects.all(),
+    assigned_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('journaling'),
         required=False,
         required=False,
         label=_('Object Type'),
         label=_('Object Type'),
-        widget=APISelectMultiple(
-            api_url='/api/extras/content-types/',
-        )
     )
     )
     kind = forms.ChoiceField(
     kind = forms.ChoiceField(
         label=_('Kind'),
         label=_('Kind'),
@@ -507,11 +504,8 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
         required=False,
         required=False,
         label=_('User')
         label=_('User')
     )
     )
-    changed_object_type_id = DynamicModelMultipleChoiceField(
-        queryset=ObjectType.objects.all(),
+    changed_object_type_id = ContentTypeMultipleChoiceField(
+        queryset=ObjectType.objects.with_feature('change_logging'),
         required=False,
         required=False,
         label=_('Object Type'),
         label=_('Object Type'),
-        widget=APISelectMultiple(
-            api_url='/api/extras/content-types/',
-        )
     )
     )

+ 71 - 1
netbox/extras/models/change_logging.py

@@ -1,12 +1,17 @@
+from functools import cached_property
+
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from mptt.models import MPTTModel
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.choices import *
 from extras.choices import *
+from netbox.models.features import ChangeLoggingMixin
+from utilities.data import shallow_compare_dict
 from ..querysets import ObjectChangeQuerySet
 from ..querysets import ObjectChangeQuerySet
 
 
 __all__ = (
 __all__ = (
@@ -136,6 +141,71 @@ class ObjectChange(models.Model):
     def get_action_color(self):
     def get_action_color(self):
         return ObjectChangeActionChoices.colors.get(self.action)
         return ObjectChangeActionChoices.colors.get(self.action)
 
 
-    @property
+    @cached_property
     def has_changes(self):
     def has_changes(self):
         return self.prechange_data != self.postchange_data
         return self.prechange_data != self.postchange_data
+
+    @cached_property
+    def diff_exclude_fields(self):
+        """
+        Return a set of attributes which should be ignored when calculating a diff
+        between the pre- and post-change data. (For instance, it would not make
+        sense to compare the "last updated" times as these are expected to differ.)
+        """
+        model = self.changed_object_type.model_class()
+        attrs = set()
+
+        # Exclude auto-populated change tracking fields
+        if issubclass(model, ChangeLoggingMixin):
+            attrs.update({'created', 'last_updated'})
+
+        # Exclude MPTT-internal fields
+        if issubclass(model, MPTTModel):
+            attrs.update({'level', 'lft', 'rght', 'tree_id'})
+
+        return attrs
+
+    def get_clean_data(self, prefix):
+        """
+        Return only the pre-/post-change attributes which are relevant for calculating a diff.
+        """
+        ret = {}
+        change_data = getattr(self, f'{prefix}_data') or {}
+        for k, v in change_data.items():
+            if k not in self.diff_exclude_fields and not k.startswith('_'):
+                ret[k] = v
+        return ret
+
+    @cached_property
+    def prechange_data_clean(self):
+        return self.get_clean_data('prechange')
+
+    @cached_property
+    def postchange_data_clean(self):
+        return self.get_clean_data('postchange')
+
+    def diff(self):
+        """
+        Return a dictionary of pre- and post-change values for attributes which have changed.
+        """
+        prechange_data = self.prechange_data_clean
+        postchange_data = self.postchange_data_clean
+
+        # Determine which attributes have changed
+        if self.action == ObjectChangeActionChoices.ACTION_CREATE:
+            changed_attrs = sorted(postchange_data.keys())
+        elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
+            changed_attrs = sorted(prechange_data.keys())
+        else:
+            # TODO: Support deep (recursive) comparison
+            changed_data = shallow_compare_dict(prechange_data, postchange_data)
+            changed_attrs = sorted(changed_data.keys())
+
+        return {
+            'pre': {
+                k: prechange_data.get(k) for k in changed_attrs
+            },
+            'post': {
+                k: postchange_data.get(k) for k in changed_attrs
+            },
+        }

+ 6 - 0
netbox/extras/models/staging.py

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.db import models, transaction
 from django.db import models, transaction
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
+from mptt.models import MPTTModel
 
 
 from extras.choices import ChangeActionChoices
 from extras.choices import ChangeActionChoices
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
@@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
             instance = self.model.objects.get(pk=self.object_id)
             instance = self.model.objects.get(pk=self.object_id)
             logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
             logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
             instance.delete()
             instance.delete()
+
+        # Rebuild the MPTT tree where applicable
+        if issubclass(self.model, MPTTModel):
+            self.model.objects.rebuild()
+
     apply.alters_data = True
     apply.alters_data = True
 
 
     def get_action_color(self):
     def get_action_color(self):

+ 8 - 21
netbox/extras/signals.py

@@ -55,18 +55,6 @@ def run_validators(instance, validators):
 clear_events = Signal()
 clear_events = Signal()
 
 
 
 
-def is_same_object(instance, webhook_data, request_id):
-    """
-    Compare the given instance to the most recent queued webhook object, returning True
-    if they match. This check is used to avoid creating duplicate webhook entries.
-    """
-    return (
-        ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
-        instance.pk == webhook_data['object_id'] and
-        request_id == webhook_data['request_id']
-    )
-
-
 @receiver((post_save, m2m_changed))
 @receiver((post_save, m2m_changed))
 def handle_changed_object(sender, instance, **kwargs):
 def handle_changed_object(sender, instance, **kwargs):
     """
     """
@@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
         objectchange.request_id = request.id
         objectchange.request_id = request.id
         objectchange.save()
         objectchange.save()
 
 
-    # If this is an M2M change, update the previously queued webhook (from post_save)
+    # Ensure that we're working with fresh M2M assignments
+    if m2m_changed:
+        instance.refresh_from_db()
+
+    # Enqueue the object for event processing
     queue = events_queue.get()
     queue = events_queue.get()
-    if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
-        instance.refresh_from_db()  # Ensure that we're working with fresh M2M assignments
-        queue[-1]['data'] = serialize_for_event(instance)
-        queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
-    else:
-        enqueue_object(queue, instance, request.user, request.id, action)
+    enqueue_object(queue, instance, request.user, request.id, action)
     events_queue.set(queue)
     events_queue.set(queue)
 
 
     # Increment metric counters
     # Increment metric counters
@@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
             obj.snapshot()  # Ensure the change record includes the "before" state
             obj.snapshot()  # Ensure the change record includes the "before" state
             getattr(obj, related_field_name).remove(instance)
             getattr(obj, related_field_name).remove(instance)
 
 
-    # Enqueue webhooks
+    # Enqueue the object for event processing
     queue = events_queue.get()
     queue = events_queue.get()
     enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
     enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
     events_queue.set(queue)
     events_queue.set(queue)
@@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
     """
     """
     logger = logging.getLogger('events')
     logger = logging.getLogger('events')
     logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
     logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
-    events_queue.set([])
+    events_queue.set({})
 
 
 
 
 #
 #

+ 28 - 0
netbox/extras/tests/test_changelog.py

@@ -75,6 +75,10 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
         self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.postchange_data)
+        self.assertNotIn('_name', oc.postchange_data_clean)
+
     def test_update_object(self):
     def test_update_object(self):
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -112,6 +116,12 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
         self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.prechange_data)
+        self.assertNotIn('_name', oc.prechange_data_clean)
+        self.assertIn('_name', oc.postchange_data)
+        self.assertNotIn('_name', oc.postchange_data_clean)
+
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
             name='Site 1',
             name='Site 1',
@@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.postchange_data, None)
         self.assertEqual(oc.postchange_data, None)
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.prechange_data)
+        self.assertNotIn('_name', oc.prechange_data_clean)
+
     def test_bulk_update_objects(self):
     def test_bulk_update_objects(self):
         sites = (
         sites = (
             Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
             Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
@@ -338,6 +352,10 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.postchange_data)
+        self.assertNotIn('_name', oc.postchange_data_clean)
+
     def test_update_object(self):
     def test_update_object(self):
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
         self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.prechange_data)
+        self.assertNotIn('_name', oc.prechange_data_clean)
+        self.assertIn('_name', oc.postchange_data)
+        self.assertNotIn('_name', oc.postchange_data_clean)
+
     def test_delete_object(self):
     def test_delete_object(self):
         site = Site(
         site = Site(
             name='Site 1',
             name='Site 1',
@@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
         self.assertEqual(oc.postchange_data, None)
         self.assertEqual(oc.postchange_data, None)
 
 
+        # Check that private attributes were included in raw data but not display data
+        self.assertIn('_name', oc.prechange_data)
+        self.assertNotIn('_name', oc.prechange_data_clean)
+
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
         data = (
         data = (
             {
             {

+ 25 - 2
netbox/extras/tests/test_event_rules.py

@@ -4,6 +4,7 @@ from unittest.mock import patch
 
 
 import django_rq
 import django_rq
 from django.http import HttpResponse
 from django.http import HttpResponse
+from django.test import RequestFactory
 from django.urls import reverse
 from django.urls import reverse
 from requests import Session
 from requests import Session
 from rest_framework import status
 from rest_framework import status
@@ -12,6 +13,7 @@ from core.models import ObjectType
 from dcim.choices import SiteStatusChoices
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
 from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
+from extras.context_managers import event_tracking
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.events import enqueue_object, flush_events, serialize_for_event
 from extras.models import EventRule, Tag, Webhook
 from extras.models import EventRule, Tag, Webhook
 from extras.webhooks import generate_signature, send_webhook
 from extras.webhooks import generate_signature, send_webhook
@@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
             return HttpResponse()
             return HttpResponse()
 
 
         # Enqueue a webhook for processing
         # Enqueue a webhook for processing
-        webhooks_queue = []
+        webhooks_queue = {}
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
         enqueue_object(
         enqueue_object(
             webhooks_queue,
             webhooks_queue,
@@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
             request_id=request_id,
             request_id=request_id,
             action=ObjectChangeActionChoices.ACTION_CREATE
             action=ObjectChangeActionChoices.ACTION_CREATE
         )
         )
-        flush_events(webhooks_queue)
+        flush_events(list(webhooks_queue.values()))
 
 
         # Retrieve the job from queue
         # Retrieve the job from queue
         job = self.queue.jobs[0]
         job = self.queue.jobs[0]
@@ -377,3 +379,24 @@ class EventRuleTest(APITestCase):
         # Patch the Session object with our dummy_send() method, then process the webhook for sending
         # Patch the Session object with our dummy_send() method, then process the webhook for sending
         with patch.object(Session, 'send', dummy_send) as mock_send:
         with patch.object(Session, 'send', dummy_send) as mock_send:
             send_webhook(**job.kwargs)
             send_webhook(**job.kwargs)
+
+    def test_duplicate_triggers(self):
+        """
+        Test for erroneous duplicate event triggers resulting from saving an object multiple times
+        within the span of a single request.
+        """
+        url = reverse('dcim:site_add')
+        request = RequestFactory().get(url)
+        request.id = uuid.uuid4()
+        request.user = self.user
+
+        self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
+
+        with event_tracking(request):
+            site = Site(name='Site 1', slug='site-1')
+            site.save()
+
+            # Save the site a second time
+            site.save()
+
+        self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")

+ 3 - 3
netbox/extras/views.py

@@ -723,15 +723,15 @@ class ObjectChangeView(generic.ObjectView):
 
 
         if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
         if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
             non_atomic_change = True
             non_atomic_change = True
-            prechange_data = prev_change.postchange_data
+            prechange_data = prev_change.postchange_data_clean
         else:
         else:
             non_atomic_change = False
             non_atomic_change = False
-            prechange_data = instance.prechange_data
+            prechange_data = instance.prechange_data_clean
 
 
         if prechange_data and instance.postchange_data:
         if prechange_data and instance.postchange_data:
             diff_added = shallow_compare_dict(
             diff_added = shallow_compare_dict(
                 prechange_data or dict(),
                 prechange_data or dict(),
-                instance.postchange_data or dict(),
+                instance.postchange_data_clean or dict(),
                 exclude=['last_updated'],
                 exclude=['last_updated'],
             )
             )
             diff_removed = {
             diff_removed = {

+ 2 - 1
netbox/ipam/models/services.py

@@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
 from ipam.choices import *
 from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
+from netbox.models.features import ContactsMixin
 from utilities.data import array_to_string
 from utilities.data import array_to_string
 
 
 __all__ = (
 __all__ = (
@@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
         return reverse('ipam:servicetemplate', args=[self.pk])
         return reverse('ipam:servicetemplate', args=[self.pk])
 
 
 
 
-class Service(ServiceBase, PrimaryModel):
+class Service(ContactsMixin, ServiceBase, PrimaryModel):
     """
     """
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
     optionally be tied to one or more specific IPAddresses belonging to its parent.
     optionally be tied to one or more specific IPAddresses belonging to its parent.

+ 1 - 1
netbox/ipam/tests/test_api.py

@@ -649,7 +649,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
         'description': 'New description',
         'description': 'New description',
     }
     }
     graphql_filter = {
     graphql_filter = {
-        'address': '192.168.0.1/24',
+        'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
     }
     }
 
 
     @classmethod
     @classmethod

+ 5 - 0
netbox/ipam/views.py

@@ -1280,3 +1280,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     queryset = Service.objects.prefetch_related('device', 'virtual_machine')
     filterset = filtersets.ServiceFilterSet
     filterset = filtersets.ServiceFilterSet
     table = tables.ServiceTable
     table = tables.ServiceTable
+
+
+@register_model_view(Service, 'contacts')
+class ServiceContactsView(ObjectContactsView):
+    queryset = Service.objects.all()

+ 1 - 1
netbox/netbox/context.py

@@ -7,4 +7,4 @@ __all__ = (
 
 
 
 
 current_request = ContextVar('current_request', default=None)
 current_request = ContextVar('current_request', default=None)
-events_queue = ContextVar('events_queue', default=[])
+events_queue = ContextVar('events_queue', default=dict())

+ 3 - 2
netbox/netbox/graphql/filter_mixins.py

@@ -23,8 +23,9 @@ def map_strawberry_type(field):
     elif isinstance(field, MultiValueArrayFilter):
     elif isinstance(field, MultiValueArrayFilter):
         pass
         pass
     elif isinstance(field, MultiValueCharFilter):
     elif isinstance(field, MultiValueCharFilter):
-        should_create_function = True
-        attr_type = List[str] | None
+        # Note: Need to use the legacy FilterLookup from filters, not from
+        # strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
+        attr_type = strawberry_django.filters.FilterLookup[str] | None
     elif isinstance(field, MultiValueDateFilter):
     elif isinstance(field, MultiValueDateFilter):
         attr_type = auto
         attr_type = auto
     elif isinstance(field, MultiValueDateTimeFilter):
     elif isinstance(field, MultiValueDateTimeFilter):

+ 1 - 1
netbox/netbox/settings.py

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 56 - 20
netbox/project-static/src/search.ts

@@ -7,38 +7,74 @@ import { isTruthy } from './util';
  */
  */
 function quickSearchEventHandler(event: Event): void {
 function quickSearchEventHandler(event: Event): void {
   const quicksearch = event.currentTarget as HTMLInputElement;
   const quicksearch = event.currentTarget as HTMLInputElement;
-  const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
+  const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
   if (isTruthy(clearbtn)) {
   if (isTruthy(clearbtn)) {
-    if (quicksearch.value === "") {
-      clearbtn.classList.add("invisible");
+    if (quicksearch.value === '') {
+      clearbtn.classList.add('invisible');
     } else {
     } else {
-      clearbtn.classList.remove("invisible");
+      clearbtn.classList.remove('invisible');
     }
     }
   }
   }
 }
 }
 
 
+/**
+ * Clear the existing search parameters in the link to export Current View.
+ */
+function clearLinkParams(): void {
+  const link = document.getElementById('export_current_view') as HTMLLinkElement;
+  const linkUpdated = link?.href.split('&')[0];
+  link.setAttribute('href', linkUpdated);
+}
+
+/**
+ * Update the Export View link to add the Quick Search parameters.
+ * @param event
+ */
+function handleQuickSearchParams(event: Event): void {
+  const quickSearchParameters = event.currentTarget as HTMLInputElement;
+
+  // Clear the existing search parameters
+  clearLinkParams();
+
+  if (quickSearchParameters != null) {
+    const link = document.getElementById('export_current_view') as HTMLLinkElement;
+    const search_parameter = `q=${quickSearchParameters.value}`;
+    const linkUpdated = link?.href + '&' + search_parameter;
+    link.setAttribute('href', linkUpdated);
+  }
+}
+
 /**
 /**
  * Initialize Quicksearch Event listener/handlers.
  * Initialize Quicksearch Event listener/handlers.
  */
  */
 export function initQuickSearch(): void {
 export function initQuickSearch(): void {
-  const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
-  const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
+  const quicksearch = document.getElementById('quicksearch') as HTMLInputElement;
+  const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
   if (isTruthy(quicksearch)) {
   if (isTruthy(quicksearch)) {
-    quicksearch.addEventListener("keyup", quickSearchEventHandler, {
-      passive: true
-    })
-    quicksearch.addEventListener("search", quickSearchEventHandler, {
-      passive: true
-    })
+    quicksearch.addEventListener('keyup', quickSearchEventHandler, {
+      passive: true,
+    });
+    quicksearch.addEventListener('search', quickSearchEventHandler, {
+      passive: true,
+    });
+    quicksearch.addEventListener('change', handleQuickSearchParams, {
+      passive: true,
+    });
+
     if (isTruthy(clearbtn)) {
     if (isTruthy(clearbtn)) {
-      clearbtn.addEventListener("click", async () => {
-        const search = new Event('search');
-        quicksearch.value = '';
-        await new Promise(f => setTimeout(f, 100));
-        quicksearch.dispatchEvent(search);
-      }, {
-        passive: true
-      })
+      clearbtn.addEventListener(
+        'click',
+        async () => {
+          const search = new Event('search');
+          quicksearch.value = '';
+          await new Promise(f => setTimeout(f, 100));
+          quicksearch.dispatchEvent(search);
+          clearLinkParams();
+        },
+        {
+          passive: true,
+        },
+      );
     }
     }
   }
   }
 }
 }

+ 2 - 2
netbox/project-static/styles/custom/_code.scss

@@ -1,7 +1,7 @@
 // Serialized data from change records
 // Serialized data from change records
 pre.change-data {
 pre.change-data {
-  padding-right: 0;
-  padding-left: 0;
+  border-radius: 0;
+  padding: 0;
 
 
   // Display each line individually for highlighting
   // Display each line individually for highlighting
   > span {
   > span {

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

@@ -1,3 +1,10 @@
+// Disable font-ligatures for Chromium based browsers
+// Chromium requires `font-variant-ligatures: none` in addition to `font-feature-settings "liga" 0`
+* {
+  font-feature-settings: "liga" 0;
+  font-variant-ligatures: none;
+}
+
 // Restore default foreground & background colors for <pre> blocks
 // Restore default foreground & background colors for <pre> blocks
 pre {
 pre {
   background-color: transparent;
   background-color: transparent;
@@ -32,3 +39,8 @@ table a {
   // Adjust table anchor link contrast as not enough contrast in dark mode
   // Adjust table anchor link contrast as not enough contrast in dark mode
   filter: brightness(110%);
   filter: brightness(110%);
 }
 }
+
+// Override background color alpha value
+[data-bs-theme=dark] ::selection {
+  background-color: rgba(var(--tblr-primary-rgb),.48)
+}

+ 1 - 1
netbox/templates/core/object_jobs.html

@@ -5,7 +5,7 @@
   <div class="row mb-3">
   <div class="row mb-3">
     <div class="col col-md-12">
     <div class="col col-md-12">
       <div class="card">
       <div class="card">
-        <div class="card-body table-responsive">
+        <div class="table-responsive">
           {% render_table table 'inc/table.html' %}
           {% render_table table 'inc/table.html' %}
           {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
           {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         </div>
         </div>

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

@@ -5,6 +5,7 @@
 {% load helpers %}
 {% load helpers %}
 {% load plugins %}
 {% load plugins %}
 {% load i18n %}
 {% load i18n %}
+{% load l10n %}
 {% load mptt %}
 {% load mptt %}
 
 
 {% block content %}
 {% block content %}
@@ -63,7 +64,7 @@
                         {% if object.latitude and object.longitude %}
                         {% if object.latitude and object.longitude %}
                           {% if config.MAPS_URL %}
                           {% if config.MAPS_URL %}
                             <div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
                             <div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
-                              <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
+                              <a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
                                 <i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
                                 <i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
                               </a>
                               </a>
                             </div>
                             </div>

+ 2 - 1
netbox/templates/dcim/site.html

@@ -3,6 +3,7 @@
 {% load plugins %}
 {% load plugins %}
 {% load tz %}
 {% load tz %}
 {% load i18n %}
 {% load i18n %}
+{% load l10n %}
 {% load mptt %}
 {% load mptt %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -95,7 +96,7 @@
             {% if object.latitude and object.longitude %}
             {% if object.latitude and object.longitude %}
               {% if config.MAPS_URL %}
               {% if config.MAPS_URL %}
                 <div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
                 <div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
-                  <a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
+                  <a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
                     <i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
                     <i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
                   </a>
                   </a>
                 </div>
                 </div>

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

@@ -1,5 +1,6 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load helpers %}
+{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block title %}{{ object }}{% endblock %}
 {% block title %}{{ object }}{% endblock %}
@@ -22,7 +23,7 @@
 {% block subtitle %}{% endblock %}
 {% block subtitle %}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<div class="row mb-3">
+<div class="row">
     <div class="col col-md-5">
     <div class="col col-md-5">
         <div class="card">
         <div class="card">
             <h5 class="card-header">{% trans "Change" %}</h5>
             <h5 class="card-header">{% trans "Change" %}</h5>
@@ -104,7 +105,7 @@
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>
-<div class="row mb-3">
+<div class="row">
     <div class="col col-md-6">
     <div class="col col-md-6">
         <div class="card">
         <div class="card">
             <h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
             <h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
@@ -112,7 +113,7 @@
             {% if object.prechange_data %}
             {% if object.prechange_data %}
               {% spaceless %}
               {% spaceless %}
                 <pre class="change-data">
                 <pre class="change-data">
-                  {% for k, v in object.prechange_data.items %}
+                  {% for k, v in object.prechange_data_clean.items %}
                     <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
                     <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
                   {% endfor %}
                   {% endfor %}
                 </pre>
                 </pre>
@@ -132,7 +133,7 @@
                 {% if object.postchange_data %}
                 {% if object.postchange_data %}
                   {% spaceless %}
                   {% spaceless %}
                     <pre class="change-data">
                     <pre class="change-data">
-                      {% for k, v in object.postchange_data.items %}
+                      {% for k, v in object.postchange_data_clean.items %}
                         <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
                         <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
                       {% endfor %}
                       {% endfor %}
                     </pre>
                     </pre>
@@ -144,7 +145,15 @@
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>
-<div class="row mb-3">
+<div class="row">
+  <div class="col col-md-6">
+    {% plugin_left_page object %}
+  </div>
+  <div class="col col-md-6">
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
     <div class="col col-md-12">
     <div class="col col-md-12">
         {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
         {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
         {% if related_changes_count > related_changes_table.rows|length %}
         {% if related_changes_count > related_changes_table.rows|length %}
@@ -158,4 +167,9 @@
         {% endif %}
         {% endif %}
     </div>
     </div>
 </div>
 </div>
+<div class="row">
+  <div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
+</div>
 {% endblock %}
 {% endblock %}

+ 2 - 2
netbox/templates/extras/script/base.html

@@ -4,7 +4,7 @@
 {% load log_levels %}
 {% load log_levels %}
 {% load i18n %}
 {% load i18n %}
 
 
-{% block title %}{{ script }}{% endblock %}
+{% block title %}{{ script.python_class.name }}{% endblock %}
 
 
 {% block object_identifier %}
 {% block object_identifier %}
   {{ script.full_name }}
   {{ script.full_name }}
@@ -17,7 +17,7 @@
 
 
 {% block subtitle %}
 {% block subtitle %}
   <div class="text-secondary fs-5">
   <div class="text-secondary fs-5">
-    {{ script.Meta.description|markdown }}
+    {{ script.python_class.Meta.description|markdown }}
   </div>
   </div>
 {% endblock subtitle %}
 {% endblock subtitle %}
 
 

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

@@ -56,15 +56,15 @@
                 <tr>
                 <tr>
                   <td>
                   <td>
                     {% if script.is_executable %}
                     {% if script.is_executable %}
-                      <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
+                      <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
                     {% else %}
                     {% else %}
-                      <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
+                      <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
                       <span class="text-danger">
                       <span class="text-danger">
                         <i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
                         <i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
                       </span>
                       </span>
                     {% endif %}
                     {% endif %}
                   </td>
                   </td>
-                  <td>{{ script.description|markdown|placeholder }}</td>
+                  <td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
                   {% if last_job %}
                   {% if last_job %}
                     <td>
                     <td>
                       <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
                       <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>

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

@@ -59,7 +59,7 @@
               <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
               <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
               <td>
               <td>
                   {% if memory_sum %}
                   {% if memory_sum %}
-                      {{ memory_sum|humanize_megabytes }}
+                      <span title={{ memory_sum }}>{{ memory_sum|humanize_megabytes }}</span>
                   {% else %}
                   {% else %}
                       {{ ''|placeholder }}
                       {{ ''|placeholder }}
                   {% endif %}
                   {% endif %}

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

@@ -125,7 +125,7 @@
                     <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
                     <th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
                     <td>
                     <td>
                         {% if object.memory %}
                         {% if object.memory %}
-                            {{ object.memory|humanize_megabytes }}
+                            <span title={{ object.memory }}>{{ object.memory|humanize_megabytes }}</span>
                         {% else %}
                         {% else %}
                             {{ ''|placeholder }}
                             {{ ''|placeholder }}
                         {% endif %}
                         {% endif %}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1242 - 1009
netbox/translations/de/LC_MESSAGES/django.po


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 3344 - 2855
netbox/translations/en/LC_MESSAGES/django.po


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1236 - 1003
netbox/translations/fr/LC_MESSAGES/django.po


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1236 - 1003
netbox/translations/pt/LC_MESSAGES/django.po


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1236 - 1003
netbox/translations/ru/LC_MESSAGES/django.po


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1236 - 1003
netbox/translations/tr/LC_MESSAGES/django.po


+ 3 - 10
netbox/utilities/serialization.py

@@ -2,7 +2,6 @@ import json
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core import serializers
 from django.core import serializers
-from mptt.models import MPTTModel
 
 
 from extras.utils import is_taggable
 from extras.utils import is_taggable
 
 
@@ -16,8 +15,7 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
     """
     """
     Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
     Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
     change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
     change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
-    can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
-    implicitly excluded.
+    can be provided to exclude them from the returned dictionary.
 
 
     Args:
     Args:
         obj: The object to serialize
         obj: The object to serialize
@@ -30,11 +28,6 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
     data = json.loads(json_str)[0]['fields']
     data = json.loads(json_str)[0]['fields']
     exclude = exclude or []
     exclude = exclude or []
 
 
-    # Exclude any MPTTModel fields
-    if issubclass(obj.__class__, MPTTModel):
-        for field in ['level', 'lft', 'rght', 'tree_id']:
-            data.pop(field)
-
     # Include custom_field_data as "custom_fields"
     # Include custom_field_data as "custom_fields"
     if hasattr(obj, 'custom_field_data'):
     if hasattr(obj, 'custom_field_data'):
         data['custom_fields'] = data.pop('custom_field_data')
         data['custom_fields'] = data.pop('custom_field_data')
@@ -45,9 +38,9 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
         tags = getattr(obj, '_tags', None) or obj.tags.all()
         tags = getattr(obj, '_tags', None) or obj.tags.all()
         data['tags'] = sorted([tag.name for tag in tags])
         data['tags'] = sorted([tag.name for tag in tags])
 
 
-    # Skip excluded and private (prefixes with an underscore) attributes
+    # Skip any excluded attributes
     for key in list(data.keys()):
     for key in list(data.keys()):
-        if key in exclude or (isinstance(key, str) and key.startswith('_')):
+        if key in exclude:
             data.pop(key)
             data.pop(key)
 
 
     # Append any extra data
     # Append any extra data

+ 1 - 1
netbox/utilities/templates/buttons/export.html

@@ -4,7 +4,7 @@
     <i class="mdi mdi-download"></i> {% trans "Export" %}
     <i class="mdi mdi-download"></i> {% trans "Export" %}
   </button>
   </button>
   <ul class="dropdown-menu dropdown-menu-end">
   <ul class="dropdown-menu dropdown-menu-end">
-    <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li>
+    <li><a id="export_current_view" class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export=table">{% trans "Current View" %}</a></li>
     <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
     <li><a class="dropdown-item" href="?{% if url_params %}{{ url_params }}&{% endif %}export">{% trans "All Data" %} ({{ data_format }})</a></li>
     {% if export_templates %}
     {% if export_templates %}
       <li>
       <li>

+ 14 - 12
netbox/utilities/templatetags/helpers.py

@@ -1,14 +1,9 @@
-import datetime
 import json
 import json
 from typing import Dict, Any
 from typing import Dict, Any
 from urllib.parse import quote
 from urllib.parse import quote
 
 
 from django import template
 from django import template
-from django.conf import settings
-from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 from django.urls import NoReverseMatch, reverse
-from django.utils import timezone
-from django.utils.safestring import mark_safe
 
 
 from core.models import ObjectType
 from core.models import ObjectType
 from utilities.forms import get_selected_values, TableConfigForm
 from utilities.forms import get_selected_values, TableConfigForm
@@ -92,15 +87,22 @@ def humanize_speed(speed):
 @register.filter()
 @register.filter()
 def humanize_megabytes(mb):
 def humanize_megabytes(mb):
     """
     """
-    Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes).
+    Express a number of megabytes in the most suitable unit (e.g. gigabytes, terabytes, etc.).
     """
     """
     if not mb:
     if not mb:
-        return ''
-    if not mb % 1048576:  # 1024^2
-        return f'{int(mb / 1048576)} TB'
-    if not mb % 1024:
-        return f'{int(mb / 1024)} GB'
-    return f'{mb} MB'
+        return ""
+
+    PB_SIZE = 1000000000
+    TB_SIZE = 1000000
+    GB_SIZE = 1000
+
+    if mb >= PB_SIZE:
+        return f"{mb / PB_SIZE:.2f} PB"
+    if mb >= TB_SIZE:
+        return f"{mb / TB_SIZE:.2f} TB"
+    if mb >= GB_SIZE:
+        return f"{mb / GB_SIZE:.2f} GB"
+    return f"{mb} MB"
 
 
 
 
 @register.filter()
 @register.filter()

+ 10 - 2
netbox/utilities/testing/api.py

@@ -493,10 +493,18 @@ class APIViewTestCases:
 
 
         def _build_filtered_query(self, name, **filters):
         def _build_filtered_query(self, name, **filters):
             """
             """
-            Create a filtered query: i.e. ip_address_list(filters: {address: "1.1.1.1/24"}){.
+            Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
             """
             """
+            # TODO: This should be extended to support AND, OR multi-lookups
             if filters:
             if filters:
-                filter_string = ', '.join(f'{k}: "{v}"' for k, v in filters.items())
+                for field_name, params in filters.items():
+                    lookup = params['lookup']
+                    value = params['value']
+                    if lookup:
+                        query = f'{{{lookup}: "{value}"}}'
+                        filter_string = f'{field_name}: {query}'
+                    else:
+                        filter_string = f'{field_name}: "{value}"'
                 filter_string = f'(filters: {{{filter_string}}})'
                 filter_string = f'(filters: {{{filter_string}}})'
             else:
             else:
                 filter_string = ''
                 filter_string = ''

+ 2 - 0
netbox/virtualization/tables/virtualmachines.py

@@ -173,6 +173,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
         default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
         default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses')
         row_attrs = {
         row_attrs = {
             'data-name': lambda record: record.name,
             'data-name': lambda record: record.name,
+            'data-virtual': lambda record: "true",
+            'data-enabled': lambda record: "true" if record.enabled else "false",
         }
         }
 
 
 
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio