mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-10 01:28:16 -06:00
Merge branch 'develop' into 16050-script-name
This commit is contained in:
commit
efee6e75af
4
.github/workflows/auto-assign-issue.yml
vendored
4
.github/workflows/auto-assign-issue.yml
vendored
@ -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
|
||||||
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@ -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
|
||||||
|
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
@ -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
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||||
|
@ -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.)
|
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.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Version and Changelog
|
### Update Version and Changelog
|
||||||
|
|
||||||
|
@ -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
|
```nohighlight
|
||||||
./manage.py makemessages -l en
|
./manage.py makemessages -l en -i "project-static/*"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
./manage.py compilemessages
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 54 KiB |
BIN
docs/media/development/transifex_pull_request.png
Normal file
BIN
docs/media/development/transifex_pull_request.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 108 KiB |
BIN
docs/media/development/transifex_sync.png
Normal file
BIN
docs/media/development/transifex_sync.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
@ -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
|
||||||
)
|
)
|
||||||
|
@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## v4.0.4 (FUTURE)
|
## 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)
|
||||||
|
@ -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 = (
|
||||||
|
@ -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)'),
|
||||||
|
@ -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):
|
||||||
|
@ -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
|
||||||
|
@ -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({})
|
||||||
|
@ -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:
|
||||||
|
@ -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({
|
assert instance.pk is not None
|
||||||
'content_type': ContentType.objects.get_for_model(instance),
|
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||||
'object_id': instance.pk,
|
if key in queue:
|
||||||
'event': action,
|
queue[key]['data'] = serialize_for_event(instance)
|
||||||
'data': serialize_for_event(instance),
|
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||||
'snapshots': get_snapshots(instance, action),
|
else:
|
||||||
'username': user.username,
|
queue[key] = {
|
||||||
'request_id': request_id
|
'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))
|
||||||
|
@ -464,13 +464,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('User')
|
label=_('User')
|
||||||
)
|
)
|
||||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
assigned_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ObjectType.objects.all(),
|
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(
|
changed_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ObjectType.objects.all(),
|
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/',
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
enqueue_object(queue, instance, request.user, request.id, action)
|
||||||
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)
|
|
||||||
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({})
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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 = (
|
||||||
{
|
{
|
||||||
|
@ -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")
|
||||||
|
@ -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 = {
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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())
|
||||||
|
@ -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
|
# Note: Need to use the legacy FilterLookup from filters, not from
|
||||||
attr_type = List[str] | None
|
# 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):
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
// Serialized data from change records
|
// Serialized data from change records
|
||||||
pre.change-data {
|
pre.change-data {
|
||||||
padding-right: 0;
|
border-radius: 0;
|
||||||
padding-left: 0;
|
padding: 0;
|
||||||
|
|
||||||
// Display each line individually for highlighting
|
// Display each line individually for highlighting
|
||||||
> span {
|
> span {
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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
|
can be provided to exclude them from the returned dictionary.
|
||||||
implicitly excluded.
|
|
||||||
|
|
||||||
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
|
||||||
|
@ -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 = ''
|
||||||
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user