mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-12 23:02:17 -06:00
Compare commits
11 Commits
v4.4.7
...
1729277496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1729277496 | ||
|
|
6efb258b9f | ||
|
|
ef29acc21e | ||
|
|
da1e0f4b53 | ||
|
|
7f39f75d3d | ||
|
|
ebf8f7fa1b | ||
|
|
922b08c0ff | ||
|
|
84864fa5e1 | ||
|
|
767dfccd8f | ||
|
|
dc4bab7477 | ||
|
|
60aa952eb1 |
@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
|
||||
|
||||
| Feature | Feature Mixin | Registry Key | Description |
|
||||
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
||||
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
|
||||
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
||||
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
||||
|
||||
@@ -472,14 +472,30 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('Unit for module weight')
|
||||
)
|
||||
attribute_data = forms.JSONField(
|
||||
label=_('Attributes'),
|
||||
required=False,
|
||||
help_text=_('Attribute values for the assigned profile, passed as a dictionary')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||
'comments', 'tags'
|
||||
'attribute_data', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Attribute data may be included only if a profile is specified
|
||||
if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
|
||||
raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
|
||||
|
||||
# Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
|
||||
if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
|
||||
self.cleaned_data['attribute_data'] = {}
|
||||
|
||||
|
||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
parent = CSVModelChoiceField(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=8,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-90.0),
|
||||
django.core.validators.MaxValueValidator(90.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=9,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-180.0),
|
||||
django.core.validators.MaxValueValidator(180.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=8,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-90.0),
|
||||
django.core.validators.MaxValueValidator(90.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=9,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-180.0),
|
||||
django.core.validators.MaxValueValidator(180.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -646,7 +646,10 @@ class Device(
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
||||
validators=[
|
||||
MinValueValidator(decimal.Decimal('-90.0')),
|
||||
MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
@@ -655,7 +658,10 @@ class Device(
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
||||
validators=[
|
||||
MinValueValidator(decimal.Decimal('-180.0')),
|
||||
MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||
)
|
||||
services = GenericRelation(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@@ -211,7 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
|
||||
validators=[
|
||||
MinValueValidator(decimal.Decimal('-90.0')),
|
||||
MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||
)
|
||||
longitude = models.DecimalField(
|
||||
@@ -220,7 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
|
||||
validators=[
|
||||
MinValueValidator(decimal.Decimal('-180.0')),
|
||||
MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
|
||||
)
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@ import logging
|
||||
import traceback
|
||||
from contextlib import ExitStack
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.signals import clear_events
|
||||
from dcim.models import Device
|
||||
from extras.models import Script as ScriptModel
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.jobs import JobRunner
|
||||
from netbox.registry import registry
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
|
||||
# A script can modify multiple models so need to do an atomic lock on
|
||||
# both the default database (for non ChangeLogged models) and potentially
|
||||
# any other database (for ChangeLogged models)
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
changeloged_db = router.db_for_write(Device)
|
||||
with transaction.atomic(using=DEFAULT_DB_ALIAS):
|
||||
# If branch database is different from default, wrap in a second atomic transaction
|
||||
# Note: Don't add any extra code between the two atomic transactions,
|
||||
# otherwise the changes might get committed to the default database
|
||||
# if there are any raised exceptions.
|
||||
if changeloged_db != DEFAULT_DB_ALIAS:
|
||||
with transaction.atomic(using=changeloged_db):
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
else:
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
except AbortTransaction:
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if script.failed:
|
||||
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
|
||||
script.request = request
|
||||
self.logger.debug(f"Request ID: {request.id if request else None}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
if commit:
|
||||
self.logger.info("Executing script (commit enabled)")
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.logger.warning("Executing script (commit disabled)")
|
||||
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
if not commit and request_processor is event_tracking:
|
||||
continue
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@@ -559,6 +559,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
form.instance._replicated_base = hasattr(self.form, "replication_fields")
|
||||
|
||||
if form.is_valid():
|
||||
changelog_message = form.cleaned_data.pop('changelog_message', '')
|
||||
new_components = []
|
||||
data = deepcopy(request.POST)
|
||||
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
|
||||
@@ -585,6 +586,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
# Create the new components
|
||||
new_objs = []
|
||||
for component_form in new_components:
|
||||
# Record changelog message (if any)
|
||||
if changelog_message:
|
||||
component_form.instance._changelog_message = changelog_message
|
||||
obj = component_form.save()
|
||||
new_objs.append(obj)
|
||||
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
import { getElements } from '../util';
|
||||
|
||||
/**
|
||||
* Move selected options from one select element to another.
|
||||
* Move selected options from one select element to another, preserving optgroup structure.
|
||||
*
|
||||
* @param source Select Element
|
||||
* @param target Select Element
|
||||
@@ -9,14 +9,42 @@ import { getElements } from '../util';
|
||||
function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
|
||||
for (const option of Array.from(source.options)) {
|
||||
if (option.selected) {
|
||||
target.appendChild(option.cloneNode(true));
|
||||
// Check if option is inside an optgroup
|
||||
const parentOptgroup = option.parentElement as HTMLElement;
|
||||
|
||||
if (parentOptgroup.tagName === 'OPTGROUP') {
|
||||
// Find or create matching optgroup in target
|
||||
const groupLabel = parentOptgroup.getAttribute('label');
|
||||
let targetOptgroup = Array.from(target.children).find(
|
||||
child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
|
||||
) as HTMLOptGroupElement;
|
||||
|
||||
if (!targetOptgroup) {
|
||||
// Create new optgroup in target
|
||||
targetOptgroup = document.createElement('optgroup');
|
||||
targetOptgroup.setAttribute('label', groupLabel!);
|
||||
target.appendChild(targetOptgroup);
|
||||
}
|
||||
|
||||
// Move option to target optgroup
|
||||
targetOptgroup.appendChild(option.cloneNode(true));
|
||||
} else {
|
||||
// Option is not in an optgroup, append directly
|
||||
target.appendChild(option.cloneNode(true));
|
||||
}
|
||||
|
||||
option.remove();
|
||||
|
||||
// Clean up empty optgroups in source
|
||||
if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
|
||||
parentOptgroup.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element up in order.
|
||||
* Move selected options of a select element up in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
element.removeChild(option);
|
||||
element.insertBefore(option, element.options[i - 1]);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const previousOption = element.options[i - 1];
|
||||
const previousParent = previousOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if previous option is in the same parent (optgroup or select)
|
||||
if (parent === previousParent) {
|
||||
parent.removeChild(option);
|
||||
parent.insertBefore(option, previousOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move selected options of a select element down in order.
|
||||
* Move selected options of a select element down in order, respecting optgroup boundaries.
|
||||
*
|
||||
* Adapted from:
|
||||
* @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
|
||||
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
|
||||
function moveOptionDown(element: HTMLSelectElement): void {
|
||||
const options = Array.from(element.options);
|
||||
for (let i = options.length - 2; i >= 0; i--) {
|
||||
let option = options[i];
|
||||
const option = options[i];
|
||||
if (option.selected) {
|
||||
let next = element.options[i + 1];
|
||||
option = element.removeChild(option);
|
||||
next = element.replaceChild(option, next);
|
||||
element.insertBefore(next, option);
|
||||
const parent = option.parentElement as HTMLElement;
|
||||
const nextOption = element.options[i + 1];
|
||||
const nextParent = nextOption.parentElement as HTMLElement;
|
||||
|
||||
// Only move if next option is in the same parent (optgroup or select)
|
||||
if (parent === nextParent) {
|
||||
const optionClone = parent.removeChild(option);
|
||||
const nextClone = parent.replaceChild(optionClone, nextOption);
|
||||
parent.insertBefore(nextClone, optionClone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,14 @@ form.object-edit {
|
||||
border: 1px solid $red;
|
||||
}
|
||||
}
|
||||
|
||||
// Make optgroup labels sticky when scrolling through select elements
|
||||
select[multiple] {
|
||||
optgroup {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bs-body-bg);
|
||||
font-weight: bold;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -283,10 +283,41 @@ class GroupForm(forms.ModelForm):
|
||||
|
||||
|
||||
def get_object_types_choices():
|
||||
return [
|
||||
(ot.pk, str(ot))
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
|
||||
]
|
||||
"""
|
||||
Generate choices for object types grouped by app label using optgroups.
|
||||
Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
|
||||
"""
|
||||
from django.apps import apps
|
||||
|
||||
choices = []
|
||||
current_app = None
|
||||
current_group = []
|
||||
|
||||
for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
|
||||
# Get verbose app label (e.g., "NetBox Branching" instead of "netbox_branching")
|
||||
try:
|
||||
app_config = apps.get_app_config(ot.app_label)
|
||||
app_label = app_config.verbose_name
|
||||
except LookupError:
|
||||
app_label = ot.app_label
|
||||
|
||||
# Start new optgroup when app changes
|
||||
if current_app != app_label:
|
||||
if current_group:
|
||||
choices.append((current_app, current_group))
|
||||
current_app = app_label
|
||||
current_group = []
|
||||
|
||||
# Add model to current group using just the model's verbose name
|
||||
model_class = ot.model_class()
|
||||
model_name = model_class._meta.verbose_name if model_class else ot.model
|
||||
current_group.append((ot.pk, model_name.title()))
|
||||
|
||||
# Add final group
|
||||
if current_group:
|
||||
choices.append((current_app, current_group))
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
class ObjectPermissionForm(forms.ModelForm):
|
||||
|
||||
@@ -72,9 +72,22 @@ class AvailableOptions(forms.SelectMultiple):
|
||||
will be empty.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) not in value
|
||||
]
|
||||
# Handle both flat choices and optgroup choices
|
||||
filtered_choices = []
|
||||
for choice in self.choices:
|
||||
# Check if this is an optgroup (nested tuple) or flat choice
|
||||
if isinstance(choice[1], (list, tuple)):
|
||||
# This is an optgroup: (group_label, [(id, name), ...])
|
||||
group_label, group_choices = choice
|
||||
filtered_group = [c for c in group_choices if str(c[0]) not in value]
|
||||
if filtered_group: # Only include optgroup if it has choices left
|
||||
filtered_choices.append((group_label, filtered_group))
|
||||
else:
|
||||
# This is a flat choice: (id, name)
|
||||
if str(choice[0]) not in value:
|
||||
filtered_choices.append(choice)
|
||||
|
||||
self.choices = filtered_choices
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
@@ -86,6 +99,12 @@ class AvailableOptions(forms.SelectMultiple):
|
||||
|
||||
return context
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
# Add title attribute to show full text on hover
|
||||
option['attrs']['title'] = label
|
||||
return option
|
||||
|
||||
|
||||
class SelectedOptions(forms.SelectMultiple):
|
||||
"""
|
||||
@@ -93,12 +112,31 @@ class SelectedOptions(forms.SelectMultiple):
|
||||
will include _all_ choices.) Employed by SplitMultiSelectWidget.
|
||||
"""
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
self.choices = [
|
||||
choice for choice in self.choices if str(choice[0]) in value
|
||||
]
|
||||
# Handle both flat choices and optgroup choices
|
||||
filtered_choices = []
|
||||
for choice in self.choices:
|
||||
# Check if this is an optgroup (nested tuple) or flat choice
|
||||
if isinstance(choice[1], (list, tuple)):
|
||||
# This is an optgroup: (group_label, [(id, name), ...])
|
||||
group_label, group_choices = choice
|
||||
filtered_group = [c for c in group_choices if str(c[0]) in value]
|
||||
if filtered_group: # Only include optgroup if it has choices left
|
||||
filtered_choices.append((group_label, filtered_group))
|
||||
else:
|
||||
# This is a flat choice: (id, name)
|
||||
if str(choice[0]) in value:
|
||||
filtered_choices.append(choice)
|
||||
|
||||
self.choices = filtered_choices
|
||||
value = [] # Clear selected choices
|
||||
return super().optgroups(name, value, attrs)
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
# Add title attribute to show full text on hover
|
||||
option['attrs']['title'] = label
|
||||
return option
|
||||
|
||||
|
||||
class SplitMultiSelectWidget(forms.MultiWidget):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user