mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 01:06:11 -06:00
Merge branch 'feature' into 12591-config-params-admin
This commit is contained in:
commit
03705a3e61
@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
|
|||||||
!!! tip
|
!!! tip
|
||||||
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
|
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
|
||||||
|
|
||||||
|
### Latitude & Longitude
|
||||||
|
|
||||||
|
GPS coordinates of the device for geolocation.
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
The device's operational status.
|
The device's operational status.
|
||||||
|
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## v3.5.4 (FUTURE)
|
## v3.5.4 (FUTURE)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
|
||||||
|
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
|
||||||
|
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
|
||||||
|
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
|
||||||
|
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
|
||||||
|
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
|
||||||
|
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
|
||||||
|
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v3.5.3 (2023-06-02)
|
## v3.5.3 (2023-06-02)
|
||||||
|
@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Enqueue a job to synchronize the DataSource.
|
Enqueue a job to synchronize the DataSource.
|
||||||
"""
|
"""
|
||||||
if not request.user.has_perm('extras.sync_datasource'):
|
if not request.user.has_perm('core.sync_datasource'):
|
||||||
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
|
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
|
||||||
|
|
||||||
datasource = get_object_or_404(DataSource, pk=pk)
|
datasource = get_object_or_404(DataSource, pk=pk)
|
||||||
|
@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
|
|
||||||
# Emit the post_sync signal
|
# Emit the post_sync signal
|
||||||
post_sync.send(sender=self.__class__, instance=self)
|
post_sync.send(sender=self.__class__, instance=self)
|
||||||
|
sync.alters_data = True
|
||||||
|
|
||||||
def _walk(self, root):
|
def _walk(self, root):
|
||||||
"""
|
"""
|
||||||
|
@ -673,9 +673,10 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
|
||||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||||
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
|
'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created',
|
||||||
|
'last_updated',
|
||||||
]
|
]
|
||||||
|
|
||||||
@extend_schema_field(NestedDeviceSerializer)
|
@extend_schema_field(NestedDeviceSerializer)
|
||||||
|
@ -646,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet):
|
|||||||
def get_view_name(self):
|
def get_view_name(self):
|
||||||
return "Connected Device Locator"
|
return "Connected Device Locator"
|
||||||
|
|
||||||
@extend_schema(responses={200: OpenApiTypes.OBJECT})
|
@extend_schema(
|
||||||
|
parameters=[_device_param, _interface_param],
|
||||||
|
responses={200: serializers.DeviceSerializer}
|
||||||
|
)
|
||||||
def list(self, request):
|
def list(self, request):
|
||||||
|
|
||||||
peer_device_name = request.query_params.get(self._device_param.name)
|
peer_device_name = request.query_params.get(self._device_param.name)
|
||||||
|
@ -999,7 +999,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
|
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
class Meta(BaseDeviceImportForm.Meta):
|
class Meta(BaseDeviceImportForm.Meta):
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||||
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
|
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
|
||||||
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
|
'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
|
||||||
|
'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
|
@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
model = Device
|
model = Device
|
||||||
fields = [
|
fields = [
|
||||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
|
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
|
||||||
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
|
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster',
|
||||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
|
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
|
||||||
'local_context_data'
|
'comments', 'tags', 'local_context_data'
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-31 22:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0173_remove_napalm_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='latitude',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='device',
|
||||||
|
name='longitude',
|
||||||
|
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
# Circuit terminations
|
# Circuit terminations
|
||||||
elif getattr(self.termination, 'site', None):
|
elif getattr(self.termination, 'site', None):
|
||||||
self._site = self.termination.site
|
self._site = self.termination.site
|
||||||
|
cache_related_objects.alters_data = True
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
objectchange = super().to_objectchange(action)
|
objectchange = super().to_objectchange(action)
|
||||||
@ -637,6 +638,7 @@ class CablePath(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
else:
|
else:
|
||||||
self.delete()
|
self.delete()
|
||||||
|
retrace.alters_data = True
|
||||||
|
|
||||||
def _get_path(self):
|
def _get_path(self):
|
||||||
"""
|
"""
|
||||||
|
@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
|||||||
type=self.type,
|
type=self.type,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
|||||||
allocated_draw=self.allocated_draw,
|
allocated_draw=self.allocated_draw,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
|||||||
feed_leg=self.feed_leg,
|
feed_leg=self.feed_leg,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
|||||||
poe_type=self.poe_type,
|
poe_type=self.poe_type,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
|||||||
rear_port_position=self.rear_port_position,
|
rear_port_position=self.rear_port_position,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
|||||||
positions=self.positions,
|
positions=self.positions,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
|||||||
label=self.label,
|
label=self.label,
|
||||||
position=self.position
|
position=self.position
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def to_yaml(self):
|
def to_yaml(self):
|
||||||
return {
|
return {
|
||||||
@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
|||||||
name=self.name,
|
name=self.name,
|
||||||
label=self.label
|
label=self.label
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
|
||||||
@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
|||||||
part_id=self.part_id,
|
part_id=self.part_id,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
instantiate.do_not_call_in_templates = True
|
||||||
|
@ -624,6 +624,20 @@ class Device(PrimaryModel, ConfigContextModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
|
latitude = models.DecimalField(
|
||||||
|
max_digits=8,
|
||||||
|
decimal_places=6,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
|
)
|
||||||
|
longitude = models.DecimalField(
|
||||||
|
max_digits=9,
|
||||||
|
decimal_places=6,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
|
||||||
|
)
|
||||||
|
|
||||||
# Generic relations
|
# Generic relations
|
||||||
contacts = GenericRelation(
|
contacts = GenericRelation(
|
||||||
|
@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin):
|
|||||||
powerport.get_power_draw()['allocated'] for powerport in powerports
|
powerport.get_power_draw()['allocated'] for powerport in powerports
|
||||||
])
|
])
|
||||||
|
|
||||||
return int(allocated_draw / available_power_total * 100)
|
return round(allocated_draw / available_power_total * 100, 1)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def total_weight(self):
|
def total_weight(self):
|
||||||
|
@ -236,9 +236,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||||
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
|
'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
|
||||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
|
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
|
||||||
'tags', 'created', 'last_updated',
|
'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||||
|
@ -1638,9 +1638,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Tenant.objects.bulk_create(tenants)
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
devices = (
|
devices = (
|
||||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
|
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}),
|
||||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
|
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]),
|
||||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
|
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]),
|
||||||
)
|
)
|
||||||
Device.objects.bulk_create(devices)
|
Device.objects.bulk_create(devices)
|
||||||
|
|
||||||
@ -1721,6 +1721,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'position': [1, 2]}
|
params = {'position': [1, 2]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_latitude(self):
|
||||||
|
params = {'latitude': [10, 20]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_longitude(self):
|
||||||
|
params = {'longitude': [10, 20]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_vc_position(self):
|
def test_vc_position(self):
|
||||||
params = {'vc_position': [1, 2]}
|
params = {'vc_position': [1, 2]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -1696,6 +1696,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'rack': racks[1].pk,
|
'rack': racks[1].pk,
|
||||||
'position': 1,
|
'position': 1,
|
||||||
'face': DeviceFaceChoices.FACE_FRONT,
|
'face': DeviceFaceChoices.FACE_FRONT,
|
||||||
|
'latitude': Decimal('35.780000'),
|
||||||
|
'longitude': Decimal('-78.642000'),
|
||||||
'status': DeviceStatusChoices.STATUS_PLANNED,
|
'status': DeviceStatusChoices.STATUS_PLANNED,
|
||||||
'primary_ip4': None,
|
'primary_ip4': None,
|
||||||
'primary_ip6': None,
|
'primary_ip6': None,
|
||||||
|
@ -2193,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConsolePortFilterSet
|
filterset = filtersets.ConsolePortFilterSet
|
||||||
filterset_form = forms.ConsolePortFilterForm
|
filterset_form = forms.ConsolePortFilterForm
|
||||||
table = tables.ConsolePortTable
|
table = tables.ConsolePortTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConsolePort)
|
@register_model_view(ConsolePort)
|
||||||
@ -2257,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ConsoleServerPortFilterSet
|
filterset = filtersets.ConsoleServerPortFilterSet
|
||||||
filterset_form = forms.ConsoleServerPortFilterForm
|
filterset_form = forms.ConsoleServerPortFilterForm
|
||||||
table = tables.ConsoleServerPortTable
|
table = tables.ConsoleServerPortTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ConsoleServerPort)
|
@register_model_view(ConsoleServerPort)
|
||||||
@ -2321,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.PowerPortFilterSet
|
filterset = filtersets.PowerPortFilterSet
|
||||||
filterset_form = forms.PowerPortFilterForm
|
filterset_form = forms.PowerPortFilterForm
|
||||||
table = tables.PowerPortTable
|
table = tables.PowerPortTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(PowerPort)
|
@register_model_view(PowerPort)
|
||||||
@ -2385,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.PowerOutletFilterSet
|
filterset = filtersets.PowerOutletFilterSet
|
||||||
filterset_form = forms.PowerOutletFilterForm
|
filterset_form = forms.PowerOutletFilterForm
|
||||||
table = tables.PowerOutletTable
|
table = tables.PowerOutletTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(PowerOutlet)
|
@register_model_view(PowerOutlet)
|
||||||
@ -2449,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.InterfaceFilterSet
|
filterset = filtersets.InterfaceFilterSet
|
||||||
filterset_form = forms.InterfaceFilterForm
|
filterset_form = forms.InterfaceFilterForm
|
||||||
table = tables.InterfaceTable
|
table = tables.InterfaceTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(Interface)
|
@register_model_view(Interface)
|
||||||
@ -2559,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.FrontPortFilterSet
|
filterset = filtersets.FrontPortFilterSet
|
||||||
filterset_form = forms.FrontPortFilterForm
|
filterset_form = forms.FrontPortFilterForm
|
||||||
table = tables.FrontPortTable
|
table = tables.FrontPortTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(FrontPort)
|
@register_model_view(FrontPort)
|
||||||
@ -2623,7 +2617,6 @@ class RearPortListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.RearPortFilterSet
|
filterset = filtersets.RearPortFilterSet
|
||||||
filterset_form = forms.RearPortFilterForm
|
filterset_form = forms.RearPortFilterForm
|
||||||
table = tables.RearPortTable
|
table = tables.RearPortTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(RearPort)
|
@register_model_view(RearPort)
|
||||||
@ -2687,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.ModuleBayFilterSet
|
filterset = filtersets.ModuleBayFilterSet
|
||||||
filterset_form = forms.ModuleBayFilterForm
|
filterset_form = forms.ModuleBayFilterForm
|
||||||
table = tables.ModuleBayTable
|
table = tables.ModuleBayTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(ModuleBay)
|
@register_model_view(ModuleBay)
|
||||||
@ -2743,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.DeviceBayFilterSet
|
filterset = filtersets.DeviceBayFilterSet
|
||||||
filterset_form = forms.DeviceBayFilterForm
|
filterset_form = forms.DeviceBayFilterForm
|
||||||
table = tables.DeviceBayTable
|
table = tables.DeviceBayTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(DeviceBay)
|
@register_model_view(DeviceBay)
|
||||||
@ -2868,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.InventoryItemFilterSet
|
filterset = filtersets.InventoryItemFilterSet
|
||||||
filterset_form = forms.InventoryItemFilterForm
|
filterset_form = forms.InventoryItemFilterForm
|
||||||
table = tables.InventoryItemTable
|
table = tables.InventoryItemTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(InventoryItem)
|
@register_model_view(InventoryItem)
|
||||||
|
@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
|||||||
Synchronize context data from the designated DataFile (if any).
|
Synchronize context data from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.data = self.data_file.get_data()
|
self.data = self.data_file.get_data()
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextModel(models.Model):
|
class ConfigContextModel(models.Model):
|
||||||
@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
|||||||
Synchronize template content from the designated DataFile (if any).
|
Synchronize template content from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.template_code = self.data_file.data_as_string
|
self.template_code = self.data_file.data_as_string
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
def render(self, context=None):
|
def render(self, context=None):
|
||||||
"""
|
"""
|
||||||
|
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
text = clean_html(text, allowed_schemes)
|
text = clean_html(text, allowed_schemes)
|
||||||
|
|
||||||
# Sanitize link
|
# Sanitize link
|
||||||
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#')
|
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
|
||||||
|
|
||||||
# Verify link scheme is allowed
|
# Verify link scheme is allowed
|
||||||
result = urllib.parse.urlparse(link)
|
result = urllib.parse.urlparse(link)
|
||||||
@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
|
|||||||
Synchronize template content from the designated DataFile (if any).
|
Synchronize template content from the designated DataFile (if any).
|
||||||
"""
|
"""
|
||||||
self.template_code = self.data_file.data_as_string
|
self.template_code = self.data_file.data_as_string
|
||||||
|
sync_data.alters_data = True
|
||||||
|
|
||||||
def render(self, queryset):
|
def render(self, queryset):
|
||||||
"""
|
"""
|
||||||
@ -628,6 +629,7 @@ class ConfigRevision(models.Model):
|
|||||||
"""
|
"""
|
||||||
cache.set('config', self.data, None)
|
cache.set('config', self.data, None)
|
||||||
cache.set('config_version', self.pk, None)
|
cache.set('config_version', self.pk, None)
|
||||||
|
activate.alters_data = True
|
||||||
|
|
||||||
@admin.display(boolean=True)
|
@admin.display(boolean=True)
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
|
@ -112,3 +112,4 @@ class StagedChange(ChangeLoggedModel):
|
|||||||
instance = self.model.objects.get(pk=self.object_id)
|
instance = self.model.objects.get(pk=self.object_id)
|
||||||
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
apply.alters_data = True
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from dcim.models import Device, Interface, Site
|
from dcim.models import Device, Interface, Site
|
||||||
@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm):
|
|||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
super().__init__(data, *args, **kwargs)
|
super().__init__(data, *args, **kwargs)
|
||||||
|
|
||||||
if data:
|
if not data:
|
||||||
|
return
|
||||||
|
|
||||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
site = data.get('site')
|
||||||
params = {}
|
vlan_group = data.get('vlan_group')
|
||||||
if data.get('site'):
|
|
||||||
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
|
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||||
if data.get('vlan_group'):
|
query = Q()
|
||||||
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
|
|
||||||
if params:
|
if site:
|
||||||
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
|
query |= Q(**{
|
||||||
|
f"site__{self.fields['site'].to_field_name}": site
|
||||||
|
})
|
||||||
|
# Don't Forget to include VLANs without a site in the filter
|
||||||
|
query |= Q(**{
|
||||||
|
f"site__{self.fields['site'].to_field_name}__isnull": True
|
||||||
|
})
|
||||||
|
|
||||||
|
if vlan_group:
|
||||||
|
query &= Q(**{
|
||||||
|
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
|
||||||
|
})
|
||||||
|
|
||||||
|
queryset = self.fields['vlan'].queryset.filter(query)
|
||||||
|
self.fields['vlan'].queryset = queryset
|
||||||
|
|
||||||
|
|
||||||
class IPRangeImportForm(NetBoxModelImportForm):
|
class IPRangeImportForm(NetBoxModelImportForm):
|
||||||
|
@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
|||||||
vlan = DynamicModelChoiceField(
|
vlan = DynamicModelChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
selector=True,
|
||||||
label=_('VLAN'),
|
label=_('VLAN'),
|
||||||
query_params={
|
|
||||||
'site_id': '$site',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
role = DynamicModelChoiceField(
|
role = DynamicModelChoiceField(
|
||||||
queryset=Role.objects.all(),
|
queryset=Role.objects.all(),
|
||||||
@ -370,7 +368,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
|||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
if address.ip == address.broadcast:
|
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||||
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
|
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||||
self.assertHttpStatus(self.client.get(url), 200)
|
self.assertHttpStatus(self.client.get(url), 200)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_prefix_import(self):
|
||||||
|
"""
|
||||||
|
Custom import test for YAML-based imports (versus CSV)
|
||||||
|
"""
|
||||||
|
IMPORT_DATA = """
|
||||||
|
prefix: 10.1.1.0/24
|
||||||
|
status: active
|
||||||
|
vlan: 101
|
||||||
|
site: Site 1
|
||||||
|
"""
|
||||||
|
# Note, a site is not tied to the VLAN to verify the fix for #12622
|
||||||
|
VLAN.objects.create(vid=101, name='VLAN101')
|
||||||
|
|
||||||
|
# Add all required permissions to the test user
|
||||||
|
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'data': IMPORT_DATA,
|
||||||
|
'format': 'yaml'
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
|
||||||
|
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||||
|
self.assertEqual(prefix.vlan.vid, 101)
|
||||||
|
self.assertEqual(prefix.site.name, "Site 1")
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_prefix_import_with_vlan_group(self):
|
||||||
|
"""
|
||||||
|
This test covers a unique import edge case where VLAN group is specified during the import.
|
||||||
|
"""
|
||||||
|
IMPORT_DATA = """
|
||||||
|
prefix: 10.1.2.0/24
|
||||||
|
status: active
|
||||||
|
vlan: 102
|
||||||
|
site: Site 1
|
||||||
|
vlan_group: Group 1
|
||||||
|
"""
|
||||||
|
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
|
||||||
|
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
|
||||||
|
|
||||||
|
# Add all required permissions to the test user
|
||||||
|
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
'data': IMPORT_DATA,
|
||||||
|
'format': 'yaml'
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
|
||||||
|
self.assertHttpStatus(response, 200)
|
||||||
|
|
||||||
|
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
|
||||||
|
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||||
|
self.assertEqual(prefix.vlan.vid, 102)
|
||||||
|
self.assertEqual(prefix.site.name, "Site 1")
|
||||||
|
|
||||||
|
|
||||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = IPRange
|
model = IPRange
|
||||||
|
@ -71,6 +71,7 @@ class ChangeLoggingMixin(models.Model):
|
|||||||
`_prechange_snapshot` on the instance.
|
`_prechange_snapshot` on the instance.
|
||||||
"""
|
"""
|
||||||
self._prechange_snapshot = self.serialize_object()
|
self._prechange_snapshot = self.serialize_object()
|
||||||
|
snapshot.alters_data = True
|
||||||
|
|
||||||
def to_objectchange(self, action):
|
def to_objectchange(self, action):
|
||||||
"""
|
"""
|
||||||
@ -244,6 +245,7 @@ class CustomFieldsMixin(models.Model):
|
|||||||
"""
|
"""
|
||||||
for cf in self.custom_fields:
|
for cf in self.custom_fields:
|
||||||
self.custom_field_data[cf.name] = cf.default
|
self.custom_field_data[cf.name] = cf.default
|
||||||
|
populate_custom_field_defaults.alters_data = True
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -419,6 +421,7 @@ class SyncedDataMixin(models.Model):
|
|||||||
self.data_synced = None
|
self.data_synced = None
|
||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
clean.alters_data = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
from core.models import AutoSyncRecord
|
from core.models import AutoSyncRecord
|
||||||
@ -466,6 +469,7 @@ class SyncedDataMixin(models.Model):
|
|||||||
self.data_synced = timezone.now()
|
self.data_synced = timezone.now()
|
||||||
if save:
|
if save:
|
||||||
self.save()
|
self.save()
|
||||||
|
sync.alters_data = True
|
||||||
|
|
||||||
def sync_data(self):
|
def sync_data(self):
|
||||||
"""
|
"""
|
||||||
|
@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
|
|||||||
label=_('Connections'),
|
label=_('Connections'),
|
||||||
items=(
|
items=(
|
||||||
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
|
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
|
||||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']),
|
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
|
||||||
MenuItem(
|
MenuItem(
|
||||||
link='dcim:interface_connections_list',
|
link='dcim:interface_connections_list',
|
||||||
link_text=_('Interface Connections'),
|
link_text=_('Interface Connections'),
|
||||||
|
@ -76,6 +76,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">GPS Coordinates</th>
|
||||||
|
<td class="position-relative">
|
||||||
|
{% if object.latitude and object.longitude %}
|
||||||
|
{% if config.MAPS_URL %}
|
||||||
|
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
|
||||||
|
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-map-marker"></i> Map It
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ ''|placeholder }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Tenant</th>
|
<th scope="row">Tenant</th>
|
||||||
<td>
|
<td>
|
||||||
|
@ -53,6 +53,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% render_field form.face %}
|
{% render_field form.face %}
|
||||||
{% render_field form.position %}
|
{% render_field form.position %}
|
||||||
|
{% render_field form.latitude %}
|
||||||
|
{% render_field form.longitude %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'inc/table_controls_htmx.html' with table_modal="ContactTable_config" %}
|
{% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
@ -179,7 +179,7 @@ def register_model_view(model, name='', path=None, kwargs=None):
|
|||||||
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
|
This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
|
||||||
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
|
additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
|
||||||
|
|
||||||
@netbox_model_view(Site, 'myview', path='my-custom-view')
|
@register_model_view(Site, 'myview', path='my-custom-view')
|
||||||
class MyView(ObjectView):
|
class MyView(ObjectView):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@ -415,7 +415,6 @@ class VMInterfaceListView(generic.ObjectListView):
|
|||||||
filterset = filtersets.VMInterfaceFilterSet
|
filterset = filtersets.VMInterfaceFilterSet
|
||||||
filterset_form = forms.VMInterfaceFilterForm
|
filterset_form = forms.VMInterfaceFilterForm
|
||||||
table = tables.VMInterfaceTable
|
table = tables.VMInterfaceTable
|
||||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(VMInterface)
|
@register_model_view(VMInterface)
|
||||||
|
Loading…
Reference in New Issue
Block a user