Merge branch 'develop' into 14567-export_current_view_ip_addresses

This commit is contained in:
Julio-Oliveira-Encora 2024-06-03 10:58:53 -03:00
commit b54d8fea47
16 changed files with 5639 additions and 4904 deletions

View File

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

View File

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

View 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'

View File

@ -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)

View File

@ -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):

View File

@ -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({})

View File

@ -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:

View File

@ -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))

View File

@ -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):

View File

@ -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({})
# #

View File

@ -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")

View File

@ -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())

View File

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

View File

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

View File

@ -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",
} }