Compare commits

...

6 Commits

Author SHA1 Message Date
Jason Novinger
c867986be0 Merge ef29acc21e into c6672538ac 2025-12-08 09:06:11 -05:00
github-actions
c6672538ac Update source translation strings
Some checks failed
Lock threads / lock (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (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
2025-12-06 05:02:07 +00:00
bctiemann
6efb258b9f Merge pull request #20908 from netbox-community/20068-import-moduletype-attrs
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
Closes #20068: Enable defining profile attributes when importing module types
2025-12-05 10:18:53 -05:00
Jason Novinger
ef29acc21e Fixes #20759: Group object types by app in permission form
Modified the ObjectPermissionForm to use optgroups for organizing
object types by application. This shortens the display names (e.g.,
"permission" instead of "Authentication and Authorization | permission")
while maintaining clear organization through visual grouping.

Changes:
- Updated get_object_types_choices() to return nested optgroup structure
- Enhanced AvailableOptions and SelectedOptions widgets to handle optgroups
- Modified TypeScript moveOptions to preserve optgroup structure
- Added hover text showing full model names
- Styled optgroups with bold, padded labels
2025-12-05 09:14:35 -06:00
github-actions
da1e0f4b53 Update source translation strings
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
2025-12-04 05:02:04 +00:00
Arthur Hanson
7f39f75d3d Fixes #20878: Use database routing when running script (#20879) 2025-12-03 17:47:31 -06:00
9 changed files with 378 additions and 235 deletions

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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