mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-09 09:08:15 -06:00
Merge branch 'develop' into 14567-export_current_view_ip_addresses
This commit is contained in:
commit
b54d8fea47
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'
|
@ -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)
|
||||||
|
@ -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):
|
||||||
|
@ -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))
|
||||||
|
@ -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({})
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -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")
|
||||||
|
@ -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())
|
||||||
|
@ -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>
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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