mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-11 06:12:16 -06:00
Compare commits
11 Commits
v4.5-beta1
...
65a6439949
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a6439949 | ||
|
|
51985863cd | ||
|
|
6efb258b9f | ||
|
|
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'))
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -681,8 +681,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.name,
|
||||
label=self.label,
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
label=self.resolve_label(kwargs.get('module')),
|
||||
position=self.position,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from jsonschema.exceptions import ValidationError as JSONValidationError
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import MODULE_TOKEN
|
||||
from dcim.utils import update_interface_bridges
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -331,7 +330,6 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
|
||||
@@ -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)')
|
||||
)
|
||||
|
||||
|
||||
@@ -792,8 +792,54 @@ class ModuleBayTestCase(TestCase):
|
||||
)
|
||||
device.consoleports.first()
|
||||
|
||||
def test_nested_module_token(self):
|
||||
pass
|
||||
@tag('regression') # #19918
|
||||
def test_nested_module_bay_label_resolution(self):
|
||||
"""Test that nested module bay labels properly resolve {module} placeholders"""
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
site = Site.objects.first()
|
||||
device_role = DeviceRole.objects.first()
|
||||
|
||||
# Create device type with module bay template (position='A')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Device with Bays',
|
||||
slug='device-with-bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
device_type=device_type,
|
||||
name='Bay A',
|
||||
position='A'
|
||||
)
|
||||
|
||||
# Create module type with nested bay template using {module} placeholder
|
||||
module_type = ModuleType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Module with Nested Bays'
|
||||
)
|
||||
ModuleBayTemplate.objects.create(
|
||||
module_type=module_type,
|
||||
name='SFP {module}-21',
|
||||
label='{module}-21',
|
||||
position='21'
|
||||
)
|
||||
|
||||
# Create device and install module
|
||||
device = Device.objects.create(
|
||||
name='Test Device',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
module_bay = device.modulebays.get(name='Bay A')
|
||||
module = Module.objects.create(
|
||||
device=device,
|
||||
module_bay=module_bay,
|
||||
module_type=module_type
|
||||
)
|
||||
|
||||
# Verify nested bay label resolves {module} to parent position
|
||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||
self.assertEqual(nested_bay.label, 'A-21')
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user