Compare commits

...

9 Commits

Author SHA1 Message Date
Jeremy Stretch
e69fc9a4b4 Pass user object to EventContext 2026-01-23 15:22:52 -05:00
Jeremy Stretch
a6c6a58fb9 Initial work on #21260 2026-01-23 14:55:58 -05:00
Arthur Hanson
a9a300197a Clear Rack Face when clear Rack (#21182)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
* #20383 clear rack face if no rack on edit

* #20383 clear rack face if no rack on edit

* review changes

* review changes
2026-01-23 12:26:27 -05:00
Jeremy Stretch
3dcca73ecc Fixes #21249: Avoid unneeded user query when no event rules are present (#21250) 2026-01-23 09:44:54 -06:00
bctiemann
4b4c542dce Add truncate_middle filter for middle-ellipsis on long filenames (#21253)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
2026-01-22 09:40:48 -08:00
github-actions
077d9b1129 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-22 05:07:49 +00:00
Aditya Sharma
e81ccb9be6 Fixes #21214: Clean up AutoSyncRecord when detaching from DataSource (#21219)
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
Lock threads / lock (push) Has been cancelled
Close stale issues/PRs / stale (push) Has been cancelled
Close incomplete issues / stale (push) Has been cancelled
Update translation strings / makemessages (push) Has been cancelled
Co-authored-by: adionit7 <adionit7@users.noreply.github.com>
2026-01-21 16:38:27 -06:00
Jeremy Stretch
bc83d04c8f Introduce performance issue template (#21247) 2026-01-21 16:34:01 -06:00
Matthew Papaleo
339ad455e4 Support for max_length and max_depth standardised for prefix_list, aggreate/prefixes and prefix/prefixes
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-21 10:02:06 -05:00
24 changed files with 306 additions and 115 deletions

View File

@@ -0,0 +1,43 @@
---
name: 🏁 Performance
type: Performance
description: An opportunity to improve application performance
labels: ["netbox", "type: performance", "status: needs triage"]
body:
- type: input
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.1
validations:
required: true
- type: dropdown
attributes:
label: Python Version
description: What version of Python are you currently running?
options:
- "3.12"
- "3.13"
- "3.14"
validations:
required: true
- type: checkboxes
attributes:
label: Area(s) of Concern
description: Which application interface(s) are affected?
options:
- label: User Interface
- label: REST API
- label: GraphQL API
- label: Python ORM
- label: Other
validations:
required: true
- type: textarea
attributes:
label: Details
description: >
Describe in detail the operations being performed and the indications of a performance issue.
Include any relevant testing parameters, benchmarks, and expected results.
validations:
required: true

View File

@@ -20,7 +20,9 @@ from utilities.forms.fields import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from utilities.forms.widgets import (
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
)
from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
@@ -592,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
},
)
)
face = forms.ChoiceField(
label=_('Face'),
choices=add_blank_choice(DeviceFaceChoices),
required=False,
widget=ClearableSelect(
requires_fields=['rack']
)
)
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(),

View File

@@ -1,5 +1,5 @@
import logging
from collections import defaultdict
from collections import UserDict, defaultdict
from django.conf import settings
from django.utils import timezone
@@ -12,7 +12,6 @@ from core.models import ObjectType
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models.features import has_feature
from users.models import User
from utilities.api import get_serializer_for_model
from utilities.request import copy_safe_request
from utilities.rqworker import get_rq_retry
@@ -23,6 +22,19 @@ from .models import EventRule
logger = logging.getLogger('netbox.events_processor')
class EventContext(UserDict):
"""
A custom dictionary that automatically serializes its associated object on demand.
"""
def __getitem__(self, item):
if item == 'data' and 'data' not in self:
data = serialize_for_event(self['object'])
self.__setitem__('data', data)
return data
return super().__getitem__(item)
def serialize_for_event(instance):
"""
Return a serialized representation of the given instance suitable for use in a queued event.
@@ -66,37 +78,42 @@ def enqueue_event(queue, instance, request, event_type):
assert instance.pk is not None
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type
else:
queue[key] = {
'object_type': ObjectType.objects.get_for_model(instance),
'object_id': instance.pk,
'event_type': event_type,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, event_type),
'request': request,
queue[key] = EventContext(
object_type=ObjectType.objects.get_for_model(instance),
object_id=instance.pk,
object=instance,
event_type=event_type,
snapshots=get_snapshots(instance, event_type),
request=request,
user=request.user,
# Legacy request attributes for backward compatibility
'username': request.user.username,
'request_id': request.id,
}
username=request.user.username,
request_id=request.id,
)
# Force serialization of objects prior to them actually being deleted
if event_type == OBJECT_DELETED:
queue[key]['data'] = serialize_for_event(instance)
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None):
user = User.objects.get(username=username) if username else None
def process_event_rules(event_rules, object_type, event):
"""
Process a list of EventRules against an event.
"""
for event_rule in event_rules:
# Evaluate event rule conditions (if any)
if not event_rule.eval_conditions(data):
if not event_rule.eval_conditions(event['data']):
continue
# Compile event data
event_data = event_rule.action_data or {}
event_data.update(data)
event_data.update(event['data'])
# Webhooks
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
@@ -109,25 +126,22 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"event_rule": event_rule,
"object_type": object_type,
"event_type": event_type,
"event_type": event['event_type'],
"data": event_data,
"snapshots": snapshots,
"snapshots": event['snapshots'],
"timestamp": timezone.now().isoformat(),
"username": username,
"username": event['username'],
"retry": get_rq_retry()
}
if snapshots:
params["snapshots"] = snapshots
if request:
if 'snapshots' in event:
params['snapshots'] = event['snapshots']
if 'request' in event:
# Exclude FILES - webhooks don't need uploaded files,
# which can cause pickle errors with Pillow.
params["request"] = copy_safe_request(request, include_files=False)
params['request'] = copy_safe_request(event['request'], include_files=False)
# Enqueue the task
rq_queue.enqueue(
"extras.webhooks.send_webhook",
**params
)
rq_queue.enqueue('extras.webhooks.send_webhook', **params)
# Scripts
elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
@@ -139,16 +153,16 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": user,
"user": event['user'],
"data": event_data
}
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
**params
)
if 'snapshots' in event:
params['snapshots'] = event['snapshots']
if 'request' in event:
params['request'] = copy_safe_request(event['request'])
# Enqueue the job
ScriptJob.enqueue(**params)
# Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
@@ -157,7 +171,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
object_type=object_type,
object_id=event_data['id'],
object_repr=event_data.get('display'),
event_type=event_type
event_type=event['event_type']
)
else:
@@ -169,6 +183,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
def process_event_queue(events):
"""
Flush a list of object representation to RQ for EventRule processing.
This is the default processor listed in EVENTS_PIPELINE.
"""
events_cache = defaultdict(dict)
@@ -188,11 +204,7 @@ def process_event_queue(events):
process_event_rules(
event_rules=event_rules,
object_type=object_type,
event_type=event['event_type'],
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request=event['request'],
event=event,
)

View File

@@ -4,7 +4,7 @@ from django.dispatch import receiver
from core.events import *
from core.signals import job_end, job_start
from extras.events import process_event_rules
from extras.events import EventContext, process_event_rules
from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config
from netbox.models.features import has_feature
@@ -102,14 +102,12 @@ def process_job_start_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_STARTED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
@receiver(job_end)
@@ -122,14 +120,12 @@ def process_job_end_event_rules(sender, **kwargs):
enabled=True,
object_types=sender.object_type
)
username = sender.user.username if sender.user else None
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event = EventContext(
event_type=JOB_COMPLETED,
data=sender.data,
username=username
user=sender.user,
)
process_event_rules(event_rules, sender.object_type, event)
#

View File

@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a>
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
"""
NOTIFICATION_ICON = """

View File

@@ -6,7 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import ValidationError
from django.test import tag, TestCase
from core.models import DataSource, ObjectType
from core.models import AutoSyncRecord, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag, TaggedItem
from tenancy.models import Tenant, TenantGroup
@@ -754,3 +754,53 @@ class ConfigTemplateTest(TestCase):
@tag('regression')
def test_config_template_with_data_source_nested_templates(self):
self.assertEqual(self.BASE_TEMPLATE, self.main_config_template.render({}))
@tag('regression')
def test_autosyncrecord_cleanup_on_detach(self):
"""Test that AutoSyncRecord is deleted when detaching from DataSource."""
with tempfile.TemporaryDirectory() as temp_dir:
templates_dir = Path(temp_dir) / "templates"
templates_dir.mkdir(parents=True, exist_ok=True)
self._create_template_file(templates_dir, 'test.j2', 'Test content')
data_source = DataSource(
name="Test DataSource for Detach",
type="local",
source_url=str(templates_dir),
)
data_source.save()
data_source.sync()
data_file = data_source.datafiles.filter(path__endswith='test.j2').first()
# Create a ConfigTemplate with data_file and auto_sync_enabled
config_template = ConfigTemplate(
name="TestTemplateForDetach",
data_file=data_file,
auto_sync_enabled=True
)
config_template.clean()
config_template.save()
# Verify AutoSyncRecord was created
object_type = ObjectType.objects.get_for_model(ConfigTemplate)
autosync_records = AutoSyncRecord.objects.filter(
object_type=object_type,
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 1, "AutoSyncRecord should be created")
# Detach from DataSource
config_template.data_file = None
config_template.data_source = None
config_template.auto_sync_enabled = False
config_template.clean()
config_template.save()
# Verify AutoSyncRecord was deleted
autosync_records = AutoSyncRecord.objects.filter(
object_type=object_type,
object_id=config_template.pk
)
self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")

View File

@@ -569,7 +569,6 @@ class SyncedDataMixin(models.Model):
)
else:
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=object_type,
object_id=self.pk
).delete()
@@ -582,7 +581,6 @@ class SyncedDataMixin(models.Model):
# Delete AutoSyncRecord
object_type = ObjectType.objects.get_for_model(self)
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=object_type,
object_id=self.pk
).delete()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
import TomSelect from 'tom-select';
import { getElements } from '../util';
/**
* Initialize clear-field dependencies.
* When a required field is cleared, dependent fields with data-requires-fields attribute will also be cleared.
*/
export function initClearField(): void {
// Find all fields with data-requires-fields attribute
for (const field of getElements<HTMLSelectElement>('[data-requires-fields]')) {
const requiredFieldsAttr = field.getAttribute('data-requires-fields');
if (!requiredFieldsAttr) continue;
// Parse the comma-separated list of required field names
const requiredFields = requiredFieldsAttr.split(',').map(name => name.trim());
// Set up listeners for each required field
for (const requiredFieldName of requiredFields) {
const requiredField = document.querySelector<HTMLSelectElement>(
`[name="${requiredFieldName}"]`,
);
if (!requiredField) continue;
// Listen for changes on the required field
requiredField.addEventListener('change', () => {
// If required field is cleared, also clear this dependent field
if (!requiredField.value || requiredField.value === '') {
// Check if this field uses TomSelect
const tomselect = (field as HTMLSelectElement & { tomselect?: TomSelect }).tomselect;
if (tomselect) {
tomselect.clear();
} else {
// Regular select field
field.value = '';
}
}
});
}
}
}

View File

@@ -1,9 +1,10 @@
import { initClearField } from './clearField';
import { initFormElements } from './elements';
import { initFilterModifiers } from './filterModifiers';
import { initSpeedSelector } from './speedSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) {
for (const func of [initFormElements, initSpeedSelector, initFilterModifiers, initClearField]) {
func();
}
}

View File

@@ -3,6 +3,8 @@
{% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %}
{% include 'ipam/inc/max_depth.html' %}
{% include 'ipam/inc/max_length.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}

View File

@@ -0,0 +1,20 @@
{% load i18n %}
{% load helpers %}
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_depth" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Max Depth" %}{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
</button>
<ul class="dropdown-menu" aria-labelledby="max_depth">
{% if request.GET.depth__lte %}
<li>
<a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=None page=1 %}">{% trans "Clear" %}</a>
</li>
{% endif %}
{% for i in 16|as_range %}
<li><a class="dropdown-item" href="{{ request.path }}{% querystring request depth__lte=i page=1 %}">
{{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
</a></li>
{% endfor %}
</ul>
</div>

View File

@@ -0,0 +1,20 @@
{% load i18n %}
{% load helpers %}
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_length" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Max Length" %}{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
</button>
<ul class="dropdown-menu" aria-labelledby="max_length">
{% if request.GET.mask_length__lte %}
<li>
<a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=None page=1 %}">{% trans "Clear" %}</a>
</li>
{% endif %}
{% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
<li><a class="dropdown-item" href="{{ request.path }}{% querystring request mask_length__lte=i page=1 %}">
{{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
</a></li>
{% endfor %}
</ul>
</div>

View File

@@ -3,6 +3,8 @@
{% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %}
{% include 'ipam/inc/max_depth.html' %}
{% include 'ipam/inc/max_length.html' %}
{% if perms.ipam.add_prefix and first_available_prefix %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Prefix" %}

View File

@@ -6,38 +6,6 @@
<button class="btn btn-outline-secondary toggle-depth" type="button">
{% trans "Hide Depth Indicators" %}
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_depth" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Max Depth" %}{% if "depth__lte" in request.GET %}: {{ request.GET.depth__lte }}{% endif %}
</button>
<ul class="dropdown-menu" aria-labelledby="max_depth">
{% if request.GET.depth__lte %}
<li>
<a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=None page=1 %}">{% trans "Clear" %}</a>
</li>
{% endif %}
{% for i in 16|as_range %}
<li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request depth__lte=i page=1 %}">
{{ i }} {% if request.GET.depth__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
</a></li>
{% endfor %}
</ul>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="max_length" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Max Length" %}{% if "mask_length__lte" in request.GET %}: {{ request.GET.mask_length__lte }}{% endif %}
</button>
<ul class="dropdown-menu" aria-labelledby="max_length">
{% if request.GET.mask_length__lte %}
<li>
<a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=None page=1 %}">{% trans "Clear" %}</a>
</li>
{% endif %}
{% for i in "4,8,12,16,20,24,28,32,40,48,56,64"|split %}
<li><a class="dropdown-item" href="{% url 'ipam:prefix_list' %}{% querystring request mask_length__lte=i page=1 %}">
{{ i }} {% if request.GET.mask_length__lte == i %}<i class="mdi mdi-check-bold"></i>{% endif %}
</a></li>
{% endfor %}
</ul>
</div>
{% include 'ipam/inc/max_depth.html' %}
{% include 'ipam/inc/max_length.html' %}
{% endblock %}

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 05:07+0000\n"
"POT-Creation-Date: 2026-01-22 05:07+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -12037,7 +12037,7 @@ msgstr ""
msgid "date synced"
msgstr ""
#: netbox/netbox/models/features.py:623
#: netbox/netbox/models/features.py:621
#, python-brace-format
msgid "{class_name} must implement a sync_data() method."
msgstr ""
@@ -13935,8 +13935,8 @@ msgid "No VLANs Assigned"
msgstr ""
#: netbox/templates/dcim/inc/interface_vlans_table.html:44
#: netbox/templates/ipam/prefix_list.html:16
#: netbox/templates/ipam/prefix_list.html:33
#: netbox/templates/ipam/inc/max_depth.html:11
#: netbox/templates/ipam/inc/max_length.html:11
msgid "Clear"
msgstr ""
@@ -15053,8 +15053,8 @@ msgstr ""
msgid "Date Added"
msgstr ""
#: netbox/templates/ipam/aggregate/prefixes.html:8
#: netbox/templates/ipam/prefix/prefixes.html:8
#: netbox/templates/ipam/aggregate/prefixes.html:10
#: netbox/templates/ipam/prefix/prefixes.html:10
#: netbox/templates/ipam/role.html:10
msgid "Add Prefix"
msgstr ""
@@ -15083,6 +15083,14 @@ msgstr ""
msgid "Bulk Create"
msgstr ""
#: netbox/templates/ipam/inc/max_depth.html:6
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/inc/max_length.html:6
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/inc/panels/fhrp_groups.html:10
msgid "Create Group"
msgstr ""
@@ -15184,14 +15192,6 @@ msgstr ""
msgid "Hide Depth Indicators"
msgstr ""
#: netbox/templates/ipam/prefix_list.html:11
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/prefix_list.html:28
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/rir.html:10
msgid "Add Aggregate"
msgstr ""

View File

@@ -5,6 +5,7 @@ from ..utils import add_blank_choice
__all__ = (
'BulkEditNullBooleanSelect',
'ClearableSelect',
'ColorSelect',
'HTMXSelect',
'SelectWithPK',
@@ -28,6 +29,21 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
)
class ClearableSelect(forms.Select):
"""
A Select widget that will be automatically cleared when one or more required fields are cleared.
Args:
requires_fields: A list of field names that this field depends on. When any of these fields
are cleared, this field will also be cleared automatically via JavaScript.
"""
def __init__(self, *args, requires_fields=None, **kwargs):
super().__init__(*args, **kwargs)
if requires_fields:
self.attrs['data-requires-fields'] = ','.join(requires_fields)
class ColorSelect(forms.Select):
"""
Extends the built-in Select widget to colorize each <option>.

View File

@@ -252,3 +252,16 @@ def isodatetime(value, spec='seconds'):
else:
return ''
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
@register.filter
def truncate_middle(value, length):
if len(value) <= length:
return value
# Calculate split points for the two parts
half_len = (length - 1) // 2 # 1 for the ellipsis
first_part = value[:half_len]
second_part = value[len(value) - (length - 1 - half_len):]
return mark_safe(f"{first_part}&hellip;{second_part}")