diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 1e47e16ae..d95416615 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.9 + placeholder: v3.3.10 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 94c879aed..de69bc9e0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.9 + placeholder: v3.3.10 validations: required: true - type: dropdown diff --git a/docs/configuration/security.md b/docs/configuration/security.md index b8c2b1e11..ae023b4d0 100644 --- a/docs/configuration/security.md +++ b/docs/configuration/security.md @@ -137,6 +137,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` diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 4dd717f9b..94dd261a2 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,18 +1,28 @@ # NetBox v3.3 -## v3.3.10 (FUTURE) +## 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 --- @@ -468,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 diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8d191b1a1..32dbbb62a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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)'), ) diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index d3afe5c08..4a2755be9 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -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: diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f2ad17117..940d05127 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -14,6 +14,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__ = ( 'CableImportForm', @@ -442,28 +443,40 @@ class DeviceImportForm(BaseDeviceImportForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ModuleImportForm(NetBoxModelImportForm): +class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm): device = CSVModelChoiceField( queryset=Device.objects.all(), - to_field_name='name' + to_field_name='name', + help_text=_('The device in which this module is installed') ) module_bay = CSVModelChoiceField( queryset=ModuleBay.objects.all(), - to_field_name='name' + to_field_name='name', + help_text=_('The module bay in which this module is installed') ) module_type = CSVModelChoiceField( queryset=ModuleType.objects.all(), - to_field_name='model' + to_field_name='model', + help_text=_('The type of module') ) status = CSVChoiceField( choices=ModuleStatusChoices, help_text=_('Operational status') ) + replicate_components = forms.BooleanField( + required=False, + help_text=_('Automatically populate components associated with this module type (enabled by default)') + ) + 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', 'status', 'description', 'comments', 'tags', + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments', + 'replicate_components', 'adopt_components', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -474,6 +487,13 @@ class ModuleImportForm(NetBoxModelImportForm): 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 ChildDeviceImportForm(BaseDeviceImportForm): parent = CSVModelChoiceField( diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 9d5232ddf..d479916d9 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -6,6 +6,7 @@ from dcim.constants import * __all__ = ( 'InterfaceCommonForm', + 'ModuleCommonForm' ) @@ -48,3 +49,61 @@ 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.get('device') + module_type = self.cleaned_data.get('module_type') + module_bay = self.cleaned_data.get('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" + ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 5b75e0cc6..1614f4bae 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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', @@ -662,7 +662,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={ @@ -727,68 +727,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): comments = CommentField() @@ -1627,6 +1565,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 = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 96b53228c..658423e52 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1129,3 +1129,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 diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3f3d28d49..42d9c7879 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -49,7 +49,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable): model = models.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', diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b8050f244..b038159b5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -17,6 +17,7 @@ from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF from tenancy.models import Tenant +from utilities.forms.choices import ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -1950,6 +1951,54 @@ 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) + + # Create a module *without* replicating components + device = Device.objects.get(name='Device 2') + module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4') + csv_data = [ + "device,module_bay,module_type,status,replicate_components", + f"{device.name},{module_bay.name},{module_type.model},active,false" + ] + request = { + 'path': self._get_url('import'), + 'data': { + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, + } + } + + initial_count = Module.objects.count() + self.assertHttpStatus(self.client.post(**request), 200) + self.assertEqual(Module.objects.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(device=device, name='Module Bay 5') + csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true" + request = { + 'path': self._get_url('import'), + 'data': { + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, + } + } + + initial_count = Module.objects.count() + self.assertHttpStatus(self.client.post(**request), 200) + self.assertEqual(Module.objects.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') @@ -1987,6 +2036,50 @@ 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,status,replicate_components,adopt_components", + f"{device.name},{module_bay.name},{module_type.model},active,false,true" + ] + request = { + 'path': self._get_url('import'), + 'data': { + 'data': '\n'.join(csv_data), + 'format': ImportFormatChoices.CSV, + } + } + + 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 diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 17e6f77c5..d16fc0daf 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -72,7 +72,7 @@ class CustomFieldsDataField(Field): # Serialize object and multi-object values 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_MULTIOBJECT ): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index c88e6021d..d890e3ebe 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -905,6 +905,18 @@ class CustomFieldAPITest(APITestCase): [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}) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 19b9e655a..fe9d42550 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -249,7 +249,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): null_option='Global' ) status = MultipleChoiceField( - choices=PrefixStatusChoices, + choices=IPRangeStatusChoices, required=False ) role_id = DynamicModelMultipleChoiceField( diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py index 2ece2c434..4ccd83e47 100644 --- a/netbox/ipam/tables/l2vpn.py +++ b/netbox/ipam/tables/l2vpn.py @@ -31,16 +31,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable): ) comments = columns.MarkdownColumn() tags = columns.TagColumn( - url_name='ipam:prefix_list' + url_name='ipam:l2vpn_list' ) class Meta(NetBoxTable.Meta): model = L2VPN fields = ( 'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group', - 'description', 'comments', 'tags', 'created', 'last_updated', 'actions', + 'description', 'comments', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions') + default_columns = ('pk', 'name', 'identifier', 'type', 'description') class L2VPNTerminationTable(NetBoxTable): diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index b3b6fbb6c..5e057d54a 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -152,6 +152,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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a43e16ab3..811be2f5d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -98,6 +98,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', []) @@ -622,8 +623,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 @@ -633,12 +632,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 = { RQ_QUEUE_HIGH: RQ_PARAMS, diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 358fea3e5..2f5c228e4 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -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 @@ -51,6 +52,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 @@ -474,6 +479,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)) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index b66a1182f..cdc21f30b 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -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') diff --git a/netbox/users/views.py b/netbox/users/views.py index 130efe3b2..832a4e592 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -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 @@ -143,7 +143,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 diff --git a/requirements.txt b/requirements.txt index c875708a4..c2c68d934 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ bleach==5.0.1 Django==4.1.2 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 @@ -29,7 +29,7 @@ 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 +tablib==3.3.0 tzdata==2022.7 # Workaround for #7401