mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-31 22:23:44 +01:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb27803ab0 | ||
|
|
5e32b39f25 | ||
|
|
b9888d6f86 | ||
|
|
96a796ebde | ||
|
|
996e73d5d8 | ||
|
|
5c969a8caf | ||
|
|
68faab8196 | ||
|
|
b3693099dc | ||
|
|
9bb9ac3dec | ||
|
|
a57378e780 | ||
|
|
41f631b65b | ||
|
|
860805ba82 | ||
|
|
1e0b024609 | ||
|
|
8486d47d17 | ||
|
|
407365888a | ||
|
|
ab9c253310 | ||
|
|
35596ddcbc | ||
|
|
0cacac82ee | ||
|
|
780997a568 | ||
|
|
d2d60c0607 | ||
|
|
d4d8d00d01 | ||
|
|
db7590df1a | ||
|
|
ee03f3d584 | ||
|
|
826a1714c3 | ||
|
|
fb407e9076 | ||
|
|
85c60670dc | ||
|
|
f2f36c67f6 | ||
|
|
281934cf34 | ||
|
|
00d72f18cf | ||
|
|
b36afdc924 | ||
|
|
4ed45e4031 | ||
|
|
cf0258204f | ||
|
|
3bd560add8 | ||
|
|
9e51a8d9d2 | ||
|
|
f59c6699f6 | ||
|
|
80f5eeacdd | ||
|
|
b1da374df2 | ||
|
|
dc1da0a738 | ||
|
|
4623858849 | ||
|
|
9c5891f1b6 | ||
|
|
d5538c1ca3 | ||
|
|
90f15b8d55 | ||
|
|
4e27e8d3dd | ||
|
|
3a89a676cd | ||
|
|
0885333b11 | ||
|
|
c287641363 | ||
|
|
de9646d096 | ||
|
|
dd2520d675 | ||
|
|
3a5914827b | ||
|
|
cf55e96241 | ||
|
|
bd29d15814 | ||
|
|
d3911e2a4c | ||
|
|
eb591731ef | ||
|
|
e40e2550a6 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.3.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.8
|
||||
placeholder: v3.3.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -129,6 +129,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
||||
|
||||
---
|
||||
|
||||
## LOGOUT_REDIRECT_URL
|
||||
|
||||
Default: `'home'`
|
||||
|
||||
The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_NAME
|
||||
|
||||
Default: `sessionid`
|
||||
|
||||
@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||
if console_port.connected_endpoint is None:
|
||||
if not console_port.connected_endpoints:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
"No console connection defined for {}".format(console_port.name)
|
||||
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
|
||||
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
if power_port.connected_endpoints:
|
||||
connected_ports += 1
|
||||
if not power_port.path.is_active:
|
||||
self.log_warning(
|
||||
|
||||
@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
* Builds the documentation locally (for offline use)
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
!!! warning
|
||||
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
|
||||
|
||||
```no-highlight
|
||||
sudo /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3.10 (2022-12-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
|
||||
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
|
||||
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
|
||||
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
|
||||
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
|
||||
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
|
||||
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
|
||||
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
|
||||
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
|
||||
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
|
||||
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
|
||||
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
|
||||
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
|
||||
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
|
||||
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
|
||||
|
||||
---
|
||||
|
||||
## v3.3.9 (2022-11-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
|
||||
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
|
||||
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
|
||||
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
|
||||
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
|
||||
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
|
||||
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
|
||||
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
|
||||
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
|
||||
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
|
||||
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
|
||||
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
|
||||
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
|
||||
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
|
||||
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
|
||||
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
|
||||
|
||||
---
|
||||
|
||||
## v3.3.8 (2022-11-16)
|
||||
|
||||
### Enhancements
|
||||
@@ -425,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
|
||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||
* Added the optional `device` field
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
wireless.WirelessLAN
|
||||
* wireless.WirelessLAN
|
||||
* Added `tenant` field
|
||||
wireless.WirelessLink
|
||||
* wireless.WirelessLink
|
||||
* Added `tenant` field
|
||||
|
||||
@@ -158,16 +158,28 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
},
|
||||
required=False
|
||||
)
|
||||
provider_network_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label='Provider',
|
||||
initial_params={
|
||||
'networks': 'provider_network'
|
||||
}
|
||||
)
|
||||
provider_network = DynamicModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
query_params={
|
||||
'provider_id': '$provider_network_provider',
|
||||
},
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
|
||||
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
|
||||
@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
||||
TYPE_4POST = '4-post-frame'
|
||||
TYPE_CABINET = '4-post-cabinet'
|
||||
TYPE_WALLFRAME = 'wall-frame'
|
||||
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||
TYPE_WALLCABINET = 'wall-cabinet'
|
||||
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_2POST, '2-post frame'),
|
||||
(TYPE_4POST, '4-post frame'),
|
||||
(TYPE_CABINET, '4-post cabinet'),
|
||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if type(value) is str:
|
||||
value = value.replace(' ', '')
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
|
||||
@@ -1218,7 +1218,7 @@ class FrontPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class RearPortBulkEditForm(
|
||||
@@ -1229,7 +1229,7 @@ class RearPortBulkEditForm(
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'color')
|
||||
|
||||
|
||||
class ModuleBayBulkEditForm(
|
||||
|
||||
@@ -13,6 +13,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableCSVForm',
|
||||
@@ -407,7 +408,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class ModuleCSVForm(NetBoxModelCSVForm):
|
||||
class ModuleCSVForm(ModuleCommonForm, NetBoxModelCSVForm):
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name'
|
||||
@@ -420,11 +421,20 @@ class ModuleCSVForm(NetBoxModelCSVForm):
|
||||
queryset=ModuleType.objects.all(),
|
||||
to_field_name='model'
|
||||
)
|
||||
replicate_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text="Automatically populate components associated with this module type (default: true)"
|
||||
)
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
help_text="Adopt already existing components"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = (
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments',
|
||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'replicate_components',
|
||||
'adopt_components', 'comments',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -435,6 +445,13 @@ class ModuleCSVForm(NetBoxModelCSVForm):
|
||||
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
||||
|
||||
def clean_replicate_components(self):
|
||||
# Make sure replicate_components is True when it's not included in the uploaded data
|
||||
if 'replicate_components' not in self.data:
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['replicate_components']
|
||||
|
||||
|
||||
class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
parent = CSVModelChoiceField(
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.constants import *
|
||||
|
||||
__all__ = (
|
||||
'InterfaceCommonForm',
|
||||
'ModuleCommonForm'
|
||||
)
|
||||
|
||||
|
||||
@@ -47,3 +48,60 @@ class InterfaceCommonForm(forms.Form):
|
||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||
f"the interface's parent device/VM, or they must be global"
|
||||
})
|
||||
|
||||
|
||||
class ModuleCommonForm(forms.Form):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
if adopt_components:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
self.instance._disable_replication = True
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.forms import (
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableForm',
|
||||
@@ -657,7 +657,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class ModuleForm(NetBoxModelForm):
|
||||
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
initial_params={
|
||||
@@ -722,68 +722,6 @@ class ModuleForm(NetBoxModelForm):
|
||||
self.fields['adopt_components'].initial = False
|
||||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If replicate_components is False, disable automatic component replication on the instance
|
||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
||||
self.instance._disable_replication = True
|
||||
|
||||
if self.cleaned_data['adopt_components']:
|
||||
self.instance._adopt_components = True
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
replicate_components = self.cleaned_data.get("replicate_components")
|
||||
adopt_components = self.cleaned_data.get("adopt_components")
|
||||
device = self.cleaned_data['device']
|
||||
module_type = self.cleaned_data['module_type']
|
||||
module_bay = self.cleaned_data['module_bay']
|
||||
|
||||
# Bail out if we are not installing a new module or if we are not replicating components
|
||||
if self.instance.pk or not replicate_components:
|
||||
return
|
||||
|
||||
for templates, component_attribute in [
|
||||
("consoleporttemplates", "consoleports"),
|
||||
("consoleserverporttemplates", "consoleserverports"),
|
||||
("interfacetemplates", "interfaces"),
|
||||
("powerporttemplates", "powerports"),
|
||||
("poweroutlettemplates", "poweroutlets"),
|
||||
("rearporttemplates", "rearports"),
|
||||
("frontporttemplates", "frontports")
|
||||
]:
|
||||
# Prefetch installed components
|
||||
installed_components = {
|
||||
component.name: component for component in getattr(device, component_attribute).all()
|
||||
}
|
||||
|
||||
# Get the templates for the module type.
|
||||
for template in getattr(module_type, templates).all():
|
||||
# Installing modules with placeholders require that the bay has a position value
|
||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||
raise forms.ValidationError(
|
||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||
)
|
||||
|
||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||
existing_item = installed_components.get(resolved_name)
|
||||
|
||||
# It is not possible to adopt components already belonging to a module
|
||||
if adopt_components and existing_item and existing_item.module:
|
||||
raise forms.ValidationError(
|
||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||
f"to a module"
|
||||
)
|
||||
|
||||
# If we are not adopting components we error if the component exists
|
||||
if not adopt_components and resolved_name in installed_components:
|
||||
raise forms.ValidationError(
|
||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||
)
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
@@ -1610,6 +1548,13 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Specifically allow editing the device of IntentoryItems
|
||||
if self.instance.pk:
|
||||
self.fields['device'].disabled = False
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
|
||||
@@ -279,6 +279,17 @@ class CableTermination(models.Model):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check for existing termination
|
||||
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
|
||||
termination_type=self.termination_type,
|
||||
termination_id=self.termination_id
|
||||
).first()
|
||||
if existing_termination is not None:
|
||||
raise ValidationError(
|
||||
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
|
||||
f"{self.termination_id}: cable {existing_termination.cable.pk}"
|
||||
)
|
||||
|
||||
# Validate interface type (if applicable)
|
||||
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
|
||||
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
|
||||
@@ -570,6 +581,7 @@ class CablePath(models.Model):
|
||||
[object_to_path_node(circuit_termination)],
|
||||
[object_to_path_node(circuit_termination.provider_network)],
|
||||
])
|
||||
is_complete = True
|
||||
break
|
||||
elif circuit_termination.site and not circuit_termination.cable:
|
||||
# Circuit terminates to a Site
|
||||
|
||||
@@ -189,7 +189,7 @@ class PathEndpoint(models.Model):
|
||||
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
|
||||
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
|
||||
|
||||
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
|
||||
"""
|
||||
_path = models.ForeignKey(
|
||||
to='dcim.CablePath',
|
||||
@@ -1153,3 +1153,20 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
# Validation for moving InventoryItems
|
||||
if self.pk:
|
||||
# Cannot move an InventoryItem to another device if it has a parent
|
||||
if self.parent and self.parent.device != self.device:
|
||||
raise ValidationError({
|
||||
"parent": "Parent inventory item does not belong to the same device."
|
||||
})
|
||||
|
||||
# Prevent moving InventoryItems with children
|
||||
first_child = self.get_children().first()
|
||||
if first_child and first_child.device != self.device:
|
||||
raise ValidationError("Cannot move an inventory item with dependent children")
|
||||
|
||||
# When moving an InventoryItem to another device, remove any associated component
|
||||
if self.component and self.component.device != self.device:
|
||||
self.component = None
|
||||
|
||||
@@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
|
||||
max_length=200
|
||||
)
|
||||
|
||||
clone_fields = ('rack', 'user', 'tenant')
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .choices import CableEndChoices, LinkStatusChoices
|
||||
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepath, rebuild_paths
|
||||
|
||||
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
|
||||
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
|
||||
cablepath.retrace()
|
||||
|
||||
|
||||
@receiver(post_save, sender=FrontPort)
|
||||
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
|
||||
"""
|
||||
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
|
||||
"""
|
||||
if created and not raw:
|
||||
rearport = instance.rear_port
|
||||
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
|
||||
cablepath.retrace()
|
||||
|
||||
@@ -63,7 +63,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
|
||||
@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
self.assertTrue(CablePath.objects.first().is_complete)
|
||||
|
||||
# Delete cable 1
|
||||
cable1.delete()
|
||||
|
||||
@@ -1848,6 +1848,53 @@ class ModuleTestCase(
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_replication(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
# Add 5 InterfaceTemplates to a ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
interface_templates = [
|
||||
InterfaceTemplate(module_type=module_type, name=f'Interface {i}') for i in range(1, 6)
|
||||
]
|
||||
InterfaceTemplate.objects.bulk_create(interface_templates)
|
||||
|
||||
form_data = self.form_data.copy()
|
||||
device = Device.objects.get(pk=form_data['device'])
|
||||
|
||||
# Create a module *without* replicating components
|
||||
module_bay = ModuleBay.objects.get(pk=form_data['module_bay'])
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,replicate_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},false"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'csv': '\n'.join(csv_data),
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
|
||||
|
||||
# Create a second module (in the next bay) with replicated components
|
||||
module_bay = ModuleBay.objects.get(pk=(form_data['module_bay'] + 1))
|
||||
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},true"
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'csv': '\n'.join(csv_data),
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_component_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
@@ -1885,6 +1932,49 @@ class ModuleTestCase(
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_module_bulk_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
|
||||
interface_name = "Interface-1"
|
||||
|
||||
# Add an interface to the ModuleType
|
||||
module_type = ModuleType.objects.first()
|
||||
InterfaceTemplate(module_type=module_type, name=interface_name).save()
|
||||
|
||||
form_data = self.form_data.copy()
|
||||
device = Device.objects.get(pk=form_data['device'])
|
||||
|
||||
# Create an interface to be adopted
|
||||
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
|
||||
interface.save()
|
||||
|
||||
# Ensure that interface is created with no module
|
||||
self.assertIsNone(interface.module)
|
||||
|
||||
# Create a module with adopted components
|
||||
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||
csv_data = [
|
||||
"device,module_bay,module_type,replicate_components,adopt_components",
|
||||
f"{device.name},{module_bay.name},{module_type.model},false,true"
|
||||
]
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': {
|
||||
'csv': '\n'.join(csv_data),
|
||||
}
|
||||
}
|
||||
|
||||
initial_count = self._get_queryset().count()
|
||||
self.assertHttpStatus(self.client.post(**request), 200)
|
||||
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||
|
||||
# Re-retrieve interface to get new module id
|
||||
interface.refresh_from_db()
|
||||
|
||||
# Check that the Interface now has a module
|
||||
self.assertIsNotNone(interface.module)
|
||||
|
||||
|
||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = ConsolePort
|
||||
|
||||
@@ -335,7 +335,7 @@ class SiteView(generic.ObjectView):
|
||||
scope_id=instance.pk
|
||||
).count(),
|
||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
|
||||
}
|
||||
locations = Location.objects.add_related_count(
|
||||
@@ -589,17 +589,18 @@ class RackElevationListView(generic.ObjectListView):
|
||||
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Ordering
|
||||
ORDERING_CHOICES = {
|
||||
'name': 'Name (A-Z)',
|
||||
'-name': 'Name (Z-A)',
|
||||
'facility_id': 'Facility ID (A-Z)',
|
||||
'-facility_id': 'Facility ID (Z-A)',
|
||||
}
|
||||
sort = request.GET.get('sort', "name")
|
||||
sort = request.GET.get('sort', 'name')
|
||||
if sort not in ORDERING_CHOICES:
|
||||
sort = 'name'
|
||||
|
||||
racks = racks.order_by(sort)
|
||||
sort_field = sort.replace("name", "_name") # Use natural ordering
|
||||
racks = racks.order_by(sort_field)
|
||||
|
||||
# Pagination
|
||||
per_page = get_paginate_count(request)
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
|
||||
#
|
||||
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
|
||||
"values."
|
||||
)
|
||||
|
||||
# Serialize object and multi-object values
|
||||
for cf in self._get_custom_fields():
|
||||
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
serializer_class = get_serializer_for_model(
|
||||
model=cf.object_type.model_class(),
|
||||
prefix=NESTED_SERIALIZER_PREFIX
|
||||
)
|
||||
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
|
||||
if serializer.is_valid():
|
||||
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
|
||||
else:
|
||||
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
|
||||
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
if self.parent.instance:
|
||||
data = {**self.parent.instance.custom_field_data, **data}
|
||||
|
||||
@@ -14,7 +14,6 @@ from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
|
||||
#
|
||||
# Change logging/webhooks
|
||||
#
|
||||
@@ -100,9 +99,6 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
# Get the current request, or bail if not set
|
||||
request = current_request.get()
|
||||
if request is None:
|
||||
@@ -110,6 +106,8 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
|
||||
instance.snapshot()
|
||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
|
||||
@@ -195,7 +195,8 @@ class ObjectChangeTable(NetBoxTable):
|
||||
object_repr = tables.TemplateColumn(
|
||||
accessor=tables.A('changed_object'),
|
||||
template_code=OBJECTCHANGE_OBJECT,
|
||||
verbose_name='Object'
|
||||
verbose_name='Object',
|
||||
orderable=False
|
||||
)
|
||||
request_id = tables.TemplateColumn(
|
||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||
|
||||
@@ -803,6 +803,69 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
|
||||
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
|
||||
|
||||
def test_specify_related_object_by_attr(self):
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
vlans = VLAN.objects.all()[:3]
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
# Set related objects by PK
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': vlans[0].pk,
|
||||
'multiobject_field': [vlans[1].pk, vlans[2].pk],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
# Set related objects by name
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': {
|
||||
'name': vlans[0].name,
|
||||
},
|
||||
'multiobject_field': [
|
||||
{
|
||||
'name': vlans[1].name
|
||||
},
|
||||
{
|
||||
'name': vlans[2].name
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
response.data['custom_fields']['object_field']['id'],
|
||||
vlans[0].pk
|
||||
)
|
||||
self.assertListEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
[vlans[1].pk, vlans[2].pk]
|
||||
)
|
||||
|
||||
# Clear related objects
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'object_field': None,
|
||||
'multiobject_field': [],
|
||||
},
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertIsNone(response.data['custom_fields']['object_field'])
|
||||
self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
|
||||
|
||||
def test_minimum_maximum_values_validation(self):
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
|
||||
@@ -960,7 +960,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = L2VPN
|
||||
fields = ['id', 'identifier', 'name', 'type', 'description']
|
||||
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -250,7 +250,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
null_option='Global'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
choices=IPRangeStatusChoices,
|
||||
required=False
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -429,7 +429,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
|
||||
if cluster := nat_inside_parent.virtual_machine.cluster:
|
||||
initial['nat_cluster'] = cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Device
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.fields import IPNetworkField, IPAddressField
|
||||
@@ -17,8 +15,7 @@ from ipam.managers import IPAddressManager
|
||||
from ipam.querysets import PrefixQuerySet
|
||||
from ipam.validators import DNSValidator
|
||||
from netbox.config import get_config
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
from netbox.models import OrganizationalModel, NetBoxModel
|
||||
|
||||
__all__ = (
|
||||
'Aggregate',
|
||||
@@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
|
||||
)
|
||||
})
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk:
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr, None) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -29,14 +29,17 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
template_code=L2VPN_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:l2vpn_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPN
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
|
||||
'actions',
|
||||
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant',
|
||||
'tenant_group', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
|
||||
|
||||
|
||||
class L2VPNTerminationTable(NetBoxTable):
|
||||
|
||||
@@ -1501,6 +1501,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'name': ['L2VPN 1', 'L2VPN 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_slug(self):
|
||||
params = {'slug': ['l2vpn-1', 'l2vpn-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_identifier(self):
|
||||
params = {'identifier': ['65001', '65002']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
# Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
|
||||
@@ -149,6 +149,9 @@ LOGIN_REQUIRED = False
|
||||
# re-authenticate. (Default: 1209600 [14 days])
|
||||
LOGIN_TIMEOUT = None
|
||||
|
||||
# The view name or URL to which users are redirected after logging out.
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
|
||||
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
|
||||
# the default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
||||
@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.3.8'
|
||||
VERSION = '3.3.10'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -102,6 +102,7 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
|
||||
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||
@@ -445,6 +446,10 @@ EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
'json': 'utilities.serializers.json',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Sentry
|
||||
@@ -621,8 +626,6 @@ if TASKS_REDIS_USING_SENTINEL:
|
||||
RQ_PARAMS = {
|
||||
'SENTINELS': TASKS_REDIS_SENTINELS,
|
||||
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SOCKET_TIMEOUT': None,
|
||||
'CONNECTION_KWARGS': {
|
||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||
@@ -632,12 +635,14 @@ else:
|
||||
RQ_PARAMS = {
|
||||
'HOST': TASKS_REDIS_HOST,
|
||||
'PORT': TASKS_REDIS_PORT,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
}
|
||||
RQ_PARAMS.update({
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
})
|
||||
|
||||
RQ_QUEUES = {
|
||||
'high': RQ_PARAMS,
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
@@ -50,6 +51,10 @@ class DateColumn(tables.DateColumn):
|
||||
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||
default, making this behavior consistent in all fields of type DateField.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value:
|
||||
return date_format(value, format="SHORT_DATE_FORMAT")
|
||||
|
||||
def value(self, value):
|
||||
return value
|
||||
|
||||
@@ -425,6 +430,12 @@ class CustomFieldColumn(tables.Column):
|
||||
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customfield.label or customfield.name
|
||||
# We can't logically sort on FK values
|
||||
if customfield.type in (
|
||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||
):
|
||||
kwargs['orderable'] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -449,6 +460,8 @@ class CustomFieldColumn(tables.Column):
|
||||
))
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
||||
return render_markdown(value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
|
||||
return date_format(parse_date(value), format="SHORT_DATE_FORMAT")
|
||||
if value is not None:
|
||||
obj = self.customfield.deserialize(value)
|
||||
return mark_safe(self._linkify_item(obj))
|
||||
|
||||
@@ -125,9 +125,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
actions = self.get_permitted_actions(request.user, model=self.child_model)
|
||||
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
|
||||
|
||||
table_data = self.prep_table_data(request, child_objects, instance)
|
||||
table = self.get_table(table_data, request, bool(actions))
|
||||
table = self.get_table(table_data, request, has_bulk_actions)
|
||||
|
||||
# If this is an HTMX request, return only the rendered table HTML
|
||||
if is_htmx(request):
|
||||
|
||||
@@ -103,14 +103,14 @@ Blocks:
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{# Bottom banner #}
|
||||
{% if config.BANNER_BOTTOM %}
|
||||
<div class="text-center mx-3">
|
||||
{{ config.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if config.BANNER_BOTTOM %}
|
||||
<div class="text-center mx-3">
|
||||
{{ config.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# BS5 pop-up modals #}
|
||||
{% block modals %}{% endblock %}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
{% render_field form.site %}
|
||||
</div>
|
||||
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
|
||||
{% render_field form.provider_network_provider %}
|
||||
{% render_field form.provider_network %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>Provider Network</td>
|
||||
<td>{{ termination.provider_network|linkify }}</td>
|
||||
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
<th>Utilization</th>
|
||||
</tr>
|
||||
{% for powerport in object.powerports.all %}
|
||||
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %}
|
||||
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
|
||||
<tr>
|
||||
<td>{{ powerport }}</td>
|
||||
<td>{{ utilization.outlet_count }}</td>
|
||||
@@ -247,10 +247,15 @@
|
||||
<td style="padding-left: 20px">Leg {{ leg.name }}</td>
|
||||
<td>{{ leg.outlet_count }}</td>
|
||||
<td>{{ leg.allocated }}</td>
|
||||
<td>{{ powerfeed.available_power|divide:3 }}VA</td>
|
||||
{% with phase_available=powerfeed.available_power|divide:3 %}
|
||||
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
|
||||
{% endwith %}
|
||||
{% if powerfeed.available_power %}
|
||||
{% with phase_available=powerfeed.available_power|divide:3 %}
|
||||
<td>{{ phase_available }}VA</td>
|
||||
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<td class="text-muted">—</td>
|
||||
<td class="text-muted">—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
<div class="card">
|
||||
<h5 class="card-header">Wireless</h5>
|
||||
<div class="card-body">
|
||||
{% with peer=object.connected_endpoint %}
|
||||
{% with peer=object.connected_endpoints.0 %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<td>{{ powerfeed|linkify }}</td>
|
||||
<td>{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}</td>
|
||||
<td>{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}</td>
|
||||
{% with power_port=powerfeed.connected_endpoint %}
|
||||
{% with power_port=powerfeed.connected_endpoints.0 %}
|
||||
{% if power_port %}
|
||||
<td>{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}</td>
|
||||
{% else %}
|
||||
|
||||
@@ -37,10 +37,13 @@ class ContactRoleTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='tenancy:contactrole_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ContactRole
|
||||
fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions')
|
||||
fields = ('pk', 'name', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions')
|
||||
default_columns = ('pk', 'name', 'description')
|
||||
|
||||
|
||||
|
||||
8
netbox/users/apps.py
Normal file
8
netbox/users/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = 'users'
|
||||
|
||||
def ready(self):
|
||||
import users.signals
|
||||
10
netbox/users/signals.py
Normal file
10
netbox/users/signals.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import logging
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.signals import user_login_failed
|
||||
|
||||
|
||||
@receiver(user_login_failed)
|
||||
def log_user_login_failed(sender, credentials, request, **kwargs):
|
||||
logger = logging.getLogger('netbox.auth.login')
|
||||
username = credentials.get("username")
|
||||
logger.info(f"Failed login attempt for username: {username}")
|
||||
@@ -7,7 +7,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||
@@ -106,7 +106,7 @@ class LoginView(View):
|
||||
return self.redirect_to_next(request, logger)
|
||||
|
||||
else:
|
||||
logger.debug("Login form validation failed")
|
||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@@ -142,7 +142,7 @@ class LogoutView(View):
|
||||
messages.info(request, "You have logged out.")
|
||||
|
||||
# Delete session key cookie (if set) upon logout
|
||||
response = HttpResponseRedirect(reverse('home'))
|
||||
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||
response.delete_cookie('session_key')
|
||||
|
||||
return response
|
||||
|
||||
@@ -28,13 +28,12 @@ class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
|
||||
serializer = super().get_request_serializer()
|
||||
|
||||
if serializer is not None and self.method in self.implicit_body_methods:
|
||||
writable_class = self.get_writable_class(serializer)
|
||||
if writable_class is not None:
|
||||
if writable_class := self.get_writable_class(serializer):
|
||||
if hasattr(serializer, 'child'):
|
||||
child_serializer = self.get_writable_class(serializer.child)
|
||||
serializer = writable_class(child=child_serializer)
|
||||
serializer = writable_class(context=serializer.context, child=child_serializer)
|
||||
else:
|
||||
serializer = writable_class()
|
||||
serializer = writable_class(context=serializer.context)
|
||||
return serializer
|
||||
|
||||
def get_writable_class(self, serializer):
|
||||
|
||||
21
netbox/utilities/serializers/json.py
Normal file
21
netbox/utilities/serializers/json.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.serializers.json import Deserializer, Serializer as Serializer_ # noqa
|
||||
from django.utils.encoding import is_protected_type
|
||||
|
||||
# NOTE: Module must contain both Serializer and Deserializer
|
||||
|
||||
|
||||
class Serializer(Serializer_):
|
||||
"""
|
||||
Custom extension of Django's JSON serializer to support ArrayFields (see
|
||||
https://code.djangoproject.com/ticket/33974).
|
||||
"""
|
||||
def _value_from_field(self, obj, field):
|
||||
value = field.value_from_object(obj)
|
||||
|
||||
# Handle ArrayFields of protected types
|
||||
if type(field) is ArrayField:
|
||||
if not value or is_protected_type(value[0]):
|
||||
return value
|
||||
|
||||
return value if is_protected_type(value) else field.value_to_string(obj)
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="form-check{% if field.errors %} has-error{% endif %}">
|
||||
{{ field }}
|
||||
<label for="{{ field.id_for_label }}" class="form-check-label">
|
||||
{{ field.label }}
|
||||
{{ label }}
|
||||
</label>
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif field|widget_type == 'textarea' and not field.label %}
|
||||
{% elif field|widget_type == 'textarea' and not label %}
|
||||
<div class="row mb-3">
|
||||
{% if label %}
|
||||
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
|
||||
@@ -48,7 +48,7 @@
|
||||
{% elif field|widget_type == 'slugwidget' %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
|
||||
{{ field.label }}
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
@@ -71,13 +71,13 @@
|
||||
accept="{{ field.field.widget.attrs.accept }}"
|
||||
{% if field.is_required %}required{% endif %}
|
||||
/>
|
||||
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
|
||||
<label for="{{ field.id_for_label }}" class="input-group-text">{{ label|bettertitle }}</label>
|
||||
</div>
|
||||
|
||||
{% elif field|widget_type == 'clearablefileinput' %}
|
||||
<div class="row mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
|
||||
{{ field.label }}
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col col-md-9">
|
||||
{{ field }}
|
||||
@@ -87,7 +87,7 @@
|
||||
{% elif field|widget_type == 'selectmultiple' %}
|
||||
<div class="row mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
|
||||
{{ field.label }}
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col col-md-9">
|
||||
{{ field }}
|
||||
@@ -103,7 +103,7 @@
|
||||
{% else %}
|
||||
<div class="row mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
|
||||
{{ field.label }}
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col">
|
||||
{{ field }}
|
||||
@@ -112,7 +112,7 @@
|
||||
{% endif %}
|
||||
<div class="invalid-feedback">
|
||||
{% if field.field.required %}
|
||||
<strong>{{ field.label }}</strong> field is required.
|
||||
<strong>{{ label }}</strong> field is required.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if bulk_nullable %}
|
||||
|
||||
@@ -40,7 +40,7 @@ def render_field(field, bulk_nullable=False, label=None):
|
||||
"""
|
||||
return {
|
||||
'field': field,
|
||||
'label': label,
|
||||
'label': label or field.label,
|
||||
'bulk_nullable': bulk_nullable,
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,8 @@ def percentage(x, y):
|
||||
"""
|
||||
if x is None or y is None:
|
||||
return None
|
||||
return round(x / y * 100)
|
||||
|
||||
return round(x / y * 100, 1)
|
||||
|
||||
|
||||
@register.filter()
|
||||
@@ -215,6 +216,7 @@ def status_from_tag(tag: str = "info") -> str:
|
||||
'warning': 'warning',
|
||||
'success': 'success',
|
||||
'error': 'danger',
|
||||
'danger': 'danger',
|
||||
'debug': 'info',
|
||||
'info': 'info',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
bleach==5.0.1
|
||||
Django==4.0.8
|
||||
django-cors-headers==3.13.0
|
||||
django-debug-toolbar==3.7.0
|
||||
django-debug-toolbar==3.8.1
|
||||
django-filter==22.1
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14
|
||||
@@ -11,7 +11,7 @@ django-redis==5.2.0
|
||||
django-rich==1.4.0
|
||||
django-rq==2.6.0
|
||||
django-tables2==2.4.1
|
||||
django-taggit==3.0.0
|
||||
django-taggit==3.1.0
|
||||
django-timezone-field==5.0
|
||||
djangorestframework==3.14.0
|
||||
drf-yasg[validation]==1.21.4
|
||||
@@ -19,21 +19,18 @@ graphene-django==2.15.0
|
||||
gunicorn==20.1.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==8.5.10
|
||||
mkdocs-material==8.5.11
|
||||
mkdocstrings[python-legacy]==0.19.0
|
||||
netaddr==0.8.0
|
||||
Pillow==9.3.0
|
||||
psycopg2-binary==2.9.5
|
||||
PyYAML==6.0
|
||||
sentry-sdk==1.11.0
|
||||
sentry-sdk==1.11.1
|
||||
social-auth-app-django==5.0.0
|
||||
social-auth-core[openidconnect]==4.3.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.2.1
|
||||
tzdata==2022.6
|
||||
tablib==3.3.0
|
||||
tzdata==2022.7
|
||||
|
||||
# Workaround for #7401
|
||||
jsonschema==3.2.0
|
||||
|
||||
# Temporary fix for #10712
|
||||
swagger-spec-validator==2.7.6
|
||||
|
||||
Reference in New Issue
Block a user