Compare commits

...

11 Commits

Author SHA1 Message Date
Jason Novinger
1729277496 Merge ef29acc21e into 6efb258b9f 2025-12-05 09:26:08 -06: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
Jeremy Stretch
ebf8f7fa1b Closes #20068: Enable defining profile attributes when importing module types 2025-12-02 16:50:59 -05:00
github-actions
922b08c0ff Update source translation strings
Some checks failed
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-02 05:02:22 +00:00
Bapths
84864fa5e1 Closes #20860: Add changlog message support for component object creation (#20898)
Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run
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
2025-12-01 17:04:21 -06:00
Jeremy Stretch
767dfccd8f Fixes #20888: Pass decimal values for min/max on latitude and longitude fields (#20892)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
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
2025-12-01 10:35:44 -08:00
Tom Gamull
dc4bab7477 docs: fix broken bookmarks link in model features table
Some checks failed
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
The bookmarks link was pointing to ../features/customization.md#bookmarks
but the bookmarks section is actually in ../features/user-preferences.md#bookmarks.

This fixes the broken anchor link.
2025-11-26 15:12:52 -05:00
github-actions
60aa952eb1 Update source translation strings
Some checks are pending
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
2025-11-26 05:02:03 +00:00
15 changed files with 509 additions and 327 deletions

View File

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

View File

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

View File

@@ -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'))
],
),
),

View File

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

View File

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

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)

View File

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

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