mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 18:08:38 -06:00
Merge pull request #11174 from netbox-community/develop
Release v3.3.10
This commit is contained in:
commit
fb27803ab0
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.3.9
|
placeholder: v3.3.10
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.3.9
|
placeholder: v3.3.10
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- 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
|
## SESSION_COOKIE_NAME
|
||||||
|
|
||||||
Default: `sessionid`
|
Default: `sessionid`
|
||||||
|
@ -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)
|
* Builds the documentation locally (for offline use)
|
||||||
* Aggregate static resource files on disk
|
* 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
|
```no-highlight
|
||||||
sudo /opt/netbox/upgrade.sh
|
sudo /opt/netbox/upgrade.sh
|
||||||
```
|
```
|
||||||
|
@ -1,5 +1,31 @@
|
|||||||
# NetBox v3.3
|
# 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)
|
## v3.3.9 (2022-11-30)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
@ -452,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.
|
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||||
* Added the optional `device` field
|
* Added the optional `device` field
|
||||||
* Added the `l2vpn_termination` read-only field
|
* Added the `l2vpn_termination` read-only field
|
||||||
wireless.WirelessLAN
|
* wireless.WirelessLAN
|
||||||
* Added `tenant` field
|
* Added `tenant` field
|
||||||
wireless.WirelessLink
|
* wireless.WirelessLink
|
||||||
* Added `tenant` field
|
* Added `tenant` field
|
||||||
|
@ -158,16 +158,28 @@ class CircuitTerminationForm(NetBoxModelForm):
|
|||||||
},
|
},
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
provider_network_provider = DynamicModelChoiceField(
|
||||||
|
queryset=Provider.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label='Provider',
|
||||||
|
initial_params={
|
||||||
|
'networks': 'provider_network'
|
||||||
|
}
|
||||||
|
)
|
||||||
provider_network = DynamicModelChoiceField(
|
provider_network = DynamicModelChoiceField(
|
||||||
queryset=ProviderNetwork.objects.all(),
|
queryset=ProviderNetwork.objects.all(),
|
||||||
|
query_params={
|
||||||
|
'provider_id': '$provider_network_provider',
|
||||||
|
},
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
fields = [
|
fields = [
|
||||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
|
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
|
||||||
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||||
|
'description', 'tags',
|
||||||
]
|
]
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'port_speed': "Physical circuit speed",
|
'port_speed': "Physical circuit speed",
|
||||||
|
@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
|||||||
TYPE_4POST = '4-post-frame'
|
TYPE_4POST = '4-post-frame'
|
||||||
TYPE_CABINET = '4-post-cabinet'
|
TYPE_CABINET = '4-post-cabinet'
|
||||||
TYPE_WALLFRAME = 'wall-frame'
|
TYPE_WALLFRAME = 'wall-frame'
|
||||||
|
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||||
TYPE_WALLCABINET = 'wall-cabinet'
|
TYPE_WALLCABINET = 'wall-cabinet'
|
||||||
|
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(TYPE_2POST, '2-post frame'),
|
(TYPE_2POST, '2-post frame'),
|
||||||
(TYPE_4POST, '4-post frame'),
|
(TYPE_4POST, '4-post frame'),
|
||||||
(TYPE_CABINET, '4-post cabinet'),
|
(TYPE_CABINET, '4-post cabinet'),
|
||||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||||
|
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
(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):
|
def to_python(self, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return value
|
return value
|
||||||
|
if type(value) is str:
|
||||||
|
value = value.replace(' ', '')
|
||||||
try:
|
try:
|
||||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||||
except AddrFormatError:
|
except AddrFormatError:
|
||||||
|
@ -13,6 +13,7 @@ from tenancy.models import Tenant
|
|||||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
from wireless.choices import WirelessRoleChoices
|
from wireless.choices import WirelessRoleChoices
|
||||||
|
from .common import ModuleCommonForm
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableCSVForm',
|
'CableCSVForm',
|
||||||
@ -407,7 +408,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
|
|||||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||||
|
|
||||||
|
|
||||||
class ModuleCSVForm(NetBoxModelCSVForm):
|
class ModuleCSVForm(ModuleCommonForm, NetBoxModelCSVForm):
|
||||||
device = CSVModelChoiceField(
|
device = CSVModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
to_field_name='name'
|
to_field_name='name'
|
||||||
@ -420,11 +421,20 @@ class ModuleCSVForm(NetBoxModelCSVForm):
|
|||||||
queryset=ModuleType.objects.all(),
|
queryset=ModuleType.objects.all(),
|
||||||
to_field_name='model'
|
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:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = (
|
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):
|
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')}
|
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||||
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
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):
|
class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||||
parent = CSVModelChoiceField(
|
parent = CSVModelChoiceField(
|
||||||
|
@ -5,6 +5,7 @@ from dcim.constants import *
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'InterfaceCommonForm',
|
'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 "
|
'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"
|
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 virtualization.models import Cluster, ClusterGroup
|
||||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||||
from .common import InterfaceCommonForm
|
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableForm',
|
'CableForm',
|
||||||
@ -657,7 +657,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||||
|
|
||||||
|
|
||||||
class ModuleForm(NetBoxModelForm):
|
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
initial_params={
|
initial_params={
|
||||||
@ -722,68 +722,6 @@ class ModuleForm(NetBoxModelForm):
|
|||||||
self.fields['adopt_components'].initial = False
|
self.fields['adopt_components'].initial = False
|
||||||
self.fields['adopt_components'].disabled = True
|
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):
|
class CableForm(TenancyForm, NetBoxModelForm):
|
||||||
|
|
||||||
@ -1610,6 +1548,13 @@ class InventoryItemForm(DeviceComponentForm):
|
|||||||
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
|
('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:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -1153,3 +1153,20 @@ class InventoryItem(MPTTModel, ComponentModel):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"parent": "Cannot assign self as parent."
|
"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
|
||||||
|
@ -63,7 +63,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
model = Manufacturer
|
model = Manufacturer
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||||
'contacts', 'actions', 'created', 'last_updated',
|
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||||
|
@ -1848,6 +1848,53 @@ class ModuleTestCase(
|
|||||||
self.assertHttpStatus(self.client.post(**request), 302)
|
self.assertHttpStatus(self.client.post(**request), 302)
|
||||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
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=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_module_component_adoption(self):
|
def test_module_component_adoption(self):
|
||||||
self.add_permissions('dcim.add_module')
|
self.add_permissions('dcim.add_module')
|
||||||
@ -1885,6 +1932,49 @@ class ModuleTestCase(
|
|||||||
# Check that the Interface now has a module
|
# Check that the Interface now has a module
|
||||||
self.assertIsNotNone(interface.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):
|
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||||
model = ConsolePort
|
model = ConsolePort
|
||||||
|
@ -335,7 +335,7 @@ class SiteView(generic.ObjectView):
|
|||||||
scope_id=instance.pk
|
scope_id=instance.pk
|
||||||
).count(),
|
).count(),
|
||||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).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(),
|
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
|
||||||
}
|
}
|
||||||
locations = Location.objects.add_related_count(
|
locations = Location.objects.add_related_count(
|
||||||
|
@ -72,7 +72,7 @@ class CustomFieldsDataField(Field):
|
|||||||
|
|
||||||
# Serialize object and multi-object values
|
# Serialize object and multi-object values
|
||||||
for cf in self._get_custom_fields():
|
for cf in self._get_custom_fields():
|
||||||
if cf.name in data and cf.type in (
|
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
||||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||||
):
|
):
|
||||||
|
@ -195,7 +195,8 @@ class ObjectChangeTable(NetBoxTable):
|
|||||||
object_repr = tables.TemplateColumn(
|
object_repr = tables.TemplateColumn(
|
||||||
accessor=tables.A('changed_object'),
|
accessor=tables.A('changed_object'),
|
||||||
template_code=OBJECTCHANGE_OBJECT,
|
template_code=OBJECTCHANGE_OBJECT,
|
||||||
verbose_name='Object'
|
verbose_name='Object',
|
||||||
|
orderable=False
|
||||||
)
|
)
|
||||||
request_id = tables.TemplateColumn(
|
request_id = tables.TemplateColumn(
|
||||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||||
|
@ -854,6 +854,18 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
[vlans[1].pk, vlans[2].pk]
|
[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):
|
def test_minimum_maximum_values_validation(self):
|
||||||
site2 = Site.objects.get(name='Site 2')
|
site2 = Site.objects.get(name='Site 2')
|
||||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||||
|
@ -960,7 +960,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = L2VPN
|
model = L2VPN
|
||||||
fields = ['id', 'identifier', 'name', 'type', 'description']
|
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
@ -250,7 +250,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
null_option='Global'
|
null_option='Global'
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = MultipleChoiceField(
|
||||||
choices=PrefixStatusChoices,
|
choices=IPRangeStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
role_id = DynamicModelMultipleChoiceField(
|
role_id = DynamicModelMultipleChoiceField(
|
||||||
|
@ -29,14 +29,17 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
template_code=L2VPN_TARGETS,
|
template_code=L2VPN_TARGETS,
|
||||||
orderable=False
|
orderable=False
|
||||||
)
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='ipam:l2vpn_list'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = L2VPN
|
model = L2VPN
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
|
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant',
|
||||||
'actions',
|
'tenant_group', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
|
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
|
||||||
|
|
||||||
|
|
||||||
class L2VPNTerminationTable(NetBoxTable):
|
class L2VPNTerminationTable(NetBoxTable):
|
||||||
|
@ -1501,6 +1501,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'name': ['L2VPN 1', 'L2VPN 2']}
|
params = {'name': ['L2VPN 1', 'L2VPN 2']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
|
def test_identifier(self):
|
||||||
params = {'identifier': ['65001', '65002']}
|
params = {'identifier': ['65001', '65002']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -149,6 +149,9 @@ LOGIN_REQUIRED = False
|
|||||||
# re-authenticate. (Default: 1209600 [14 days])
|
# re-authenticate. (Default: 1209600 [14 days])
|
||||||
LOGIN_TIMEOUT = None
|
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 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.
|
# the default value of this setting is derived from the installed location.
|
||||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||||
|
@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.3.9'
|
VERSION = '3.3.10'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
@ -102,6 +102,7 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
|||||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
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('/')
|
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||||
@ -625,8 +626,6 @@ if TASKS_REDIS_USING_SENTINEL:
|
|||||||
RQ_PARAMS = {
|
RQ_PARAMS = {
|
||||||
'SENTINELS': TASKS_REDIS_SENTINELS,
|
'SENTINELS': TASKS_REDIS_SENTINELS,
|
||||||
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
||||||
'DB': TASKS_REDIS_DATABASE,
|
|
||||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
|
||||||
'SOCKET_TIMEOUT': None,
|
'SOCKET_TIMEOUT': None,
|
||||||
'CONNECTION_KWARGS': {
|
'CONNECTION_KWARGS': {
|
||||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||||
@ -636,12 +635,14 @@ else:
|
|||||||
RQ_PARAMS = {
|
RQ_PARAMS = {
|
||||||
'HOST': TASKS_REDIS_HOST,
|
'HOST': TASKS_REDIS_HOST,
|
||||||
'PORT': TASKS_REDIS_PORT,
|
'PORT': TASKS_REDIS_PORT,
|
||||||
'DB': TASKS_REDIS_DATABASE,
|
|
||||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
|
||||||
'SSL': TASKS_REDIS_SSL,
|
'SSL': TASKS_REDIS_SSL,
|
||||||
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
'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 = {
|
RQ_QUEUES = {
|
||||||
'high': RQ_PARAMS,
|
'high': RQ_PARAMS,
|
||||||
|
@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
|||||||
from django.db.models import DateField, DateTimeField
|
from django.db.models import DateField, DateTimeField
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils.encoding import escape_uri_path
|
from django.utils.encoding import escape_uri_path
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.formats import date_format
|
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
|
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.
|
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):
|
def value(self, value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -455,6 +460,8 @@ class CustomFieldColumn(tables.Column):
|
|||||||
))
|
))
|
||||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
||||||
return render_markdown(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:
|
if value is not None:
|
||||||
obj = self.customfield.deserialize(value)
|
obj = self.customfield.deserialize(value)
|
||||||
return mark_safe(self._linkify_item(obj))
|
return mark_safe(self._linkify_item(obj))
|
||||||
|
@ -103,14 +103,14 @@ Blocks:
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{# Bottom banner #}
|
||||||
|
{% if config.BANNER_BOTTOM %}
|
||||||
|
<div class="text-center mx-3">
|
||||||
|
{{ config.BANNER_BOTTOM|safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if config.BANNER_BOTTOM %}
|
|
||||||
<div class="text-center mx-3">
|
|
||||||
{{ config.BANNER_BOTTOM|safe }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# BS5 pop-up modals #}
|
{# BS5 pop-up modals #}
|
||||||
{% block modals %}{% endblock %}
|
{% block modals %}{% endblock %}
|
||||||
|
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
{% render_field form.site %}
|
{% render_field form.site %}
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
|
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
|
||||||
|
{% render_field form.provider_network_provider %}
|
||||||
{% render_field form.provider_network %}
|
{% render_field form.provider_network %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -81,7 +81,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>Provider Network</td>
|
<td>Provider Network</td>
|
||||||
<td>{{ termination.provider_network|linkify }}</td>
|
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -37,10 +37,13 @@ class ContactRoleTable(NetBoxTable):
|
|||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='tenancy:contactrole_list'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ContactRole
|
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')
|
default_columns = ('pk', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.contrib.auth.models import update_last_login
|
from django.contrib.auth.models import update_last_login
|
||||||
from django.contrib.auth.signals import user_logged_in
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.http import HttpResponseRedirect
|
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.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||||
@ -142,7 +142,7 @@ class LogoutView(View):
|
|||||||
messages.info(request, "You have logged out.")
|
messages.info(request, "You have logged out.")
|
||||||
|
|
||||||
# Delete session key cookie (if set) upon logout
|
# 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')
|
response.delete_cookie('session_key')
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -138,7 +138,8 @@ def percentage(x, y):
|
|||||||
"""
|
"""
|
||||||
if x is None or y is None:
|
if x is None or y is None:
|
||||||
return None
|
return None
|
||||||
return round(x / y * 100)
|
|
||||||
|
return round(x / y * 100, 1)
|
||||||
|
|
||||||
|
|
||||||
@register.filter()
|
@register.filter()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
bleach==5.0.1
|
bleach==5.0.1
|
||||||
Django==4.0.8
|
Django==4.0.8
|
||||||
django-cors-headers==3.13.0
|
django-cors-headers==3.13.0
|
||||||
django-debug-toolbar==3.7.0
|
django-debug-toolbar==3.8.1
|
||||||
django-filter==22.1
|
django-filter==22.1
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.14
|
django-mptt==0.14
|
||||||
@ -29,7 +29,7 @@ sentry-sdk==1.11.1
|
|||||||
social-auth-app-django==5.0.0
|
social-auth-app-django==5.0.0
|
||||||
social-auth-core[openidconnect]==4.3.0
|
social-auth-core[openidconnect]==4.3.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.2.1
|
tablib==3.3.0
|
||||||
tzdata==2022.7
|
tzdata==2022.7
|
||||||
|
|
||||||
# Workaround for #7401
|
# Workaround for #7401
|
||||||
|
Loading…
Reference in New Issue
Block a user