Compare commits

...

38 Commits

Author SHA1 Message Date
Smixi 34b7c3a9a1
Merge 832f7452f2 into 85db007ff5 2024-04-26 13:36:57 -03:00
Daniel Sheppard 85db007ff5
Update changelog for #14750 2024-04-22 21:57:40 -05:00
Daniel Sheppard cad3e34d8f
Merge pull request #14750 from Moehritz/13922-svg-uneven
Fixes #14241, Fixes #13922: Update the CableRender
2024-04-22 21:53:34 -05:00
Daniel Sheppard 7b1b91b8ee
Correct wording for #13874 2024-04-22 21:51:54 -05:00
Daniel Sheppard 6f36b8513c
Update changelog for #13874 2024-04-22 21:51:08 -05:00
Daniel Sheppard 07e2cf0ad2
Merge pull request #13874 from pv2b/choices-css-rewrite
Refactor row coloring logic and simplify mark planned/connected toggle implementation
2024-04-22 21:45:15 -05:00
Jeremy Stretch d606cf1b3c Update source translations 2024-04-22 15:50:38 -04:00
Jeremy Stretch 0b0dab42eb PRVB 2024-04-22 12:23:31 -04:00
Jeremy Stretch d115601da3
Merge pull request #15805 from netbox-community/develop
Release v3.7.6
2024-04-22 12:18:27 -04:00
Jeremy Stretch a61e20849b Release v3.7.6 2024-04-22 11:46:03 -04:00
Arthur Hanson 1eca1c3d17
15803 localize help_text (#15804) 2024-04-22 11:42:20 -04:00
transifex-integration[bot] 5d95d49268
Update translations 2024-04-22 11:28:04 -04:00
Jeremy Stretch 6b8bfe9947 Changelog for #14690, #15541, #15588, #15761, #15771, #15790 2024-04-22 11:25:21 -04:00
Jeremy Stretch e87877b6ea Fixes #15771: Show id field as supported on all bulk import forms 2024-04-22 11:08:36 -04:00
Jeremy Stretch ebe504c825 Closes #15664: Restore usage of READTHEDOCS env variable 2024-04-22 09:52:03 -04:00
Markku Leiniö b6e38b2ebe
Closes #14690: Pretty-format JSON fields in the config form (#15623)
* Closes #14690: Pretty-format JSON fields in the config form

* Revert changes

* Use our own JSONField for config parameters for pretty editor outputs

* Compare identity instead of equality
2024-04-22 09:25:16 -04:00
Arthur Hanson 90d0104359
15541 Add component selector to InventoryItemTemplate (#15759)
* 15541 make inventoryitemtemplateform match inventoryitemform

* 15541 set tab active
2024-04-22 08:22:53 -04:00
Arthur Hanson 88facbafbb
15761 filter IKE Proposals on IKE Policy detail view (#15766)
* 15761 filter IKEAProposals on IKEAPolicy detail view

* Add test for ike_policy filter

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-19 17:09:55 -04:00
Jeremy Stretch c9de3128ca Fixes #15790: Fix live preview support for EventRule comments 2024-04-19 17:09:02 -04:00
Arthur 94c31622ac 15588 set readonly nullable fields as allow_null=True 2024-04-19 16:17:28 -04:00
Jeremy Stretch 3d3c1c315b Update documentation for the DEFAULT_LANGUAGE configuration parameter 2024-04-19 16:15:32 -04:00
Jeff Gehlbach f4c8f5f5b6 Add link to plugin certification program details in Plugin module of docs. Fixes #15769 2024-04-19 08:49:13 -04:00
Jeremy Stretch 19fe5ef25c Changelog for #15427, #15582, #15635 2024-04-17 16:18:57 -04:00
Arthur Hanson 928014c766
5509 Add Test cases for Custom Fields (#12312)
* 5509 add content type data to model tests create and update

* 5509 update use cf form data

* 5509 update tests to use CustomFieldTypeChoices

* 5509 update tests to check custom fields

* Simplify custom fields used for testing

* Move custom field data functions to testing.utils

* Move validate_custom_field_data() into assertInstanceEqual()

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-17 16:05:05 -04:00
Sami Tahri 832f7452f2 fix: SerializedPKRelatedField schema now use nested serializer or response 2024-02-04 01:24:19 +01:00
Per von Zweigbergk 8fadd6b744 Merge branch 'develop' into choices-css-rewrite 2024-01-23 21:50:06 +01:00
Per von Zweigbergk c93413dc9c Move interface colour logic into SCSS where it belongs 2024-01-23 21:33:09 +01:00
Per von Zweigbergk bf362f4679 Hardcode cable status colours 2024-01-23 20:58:10 +01:00
Per von Zweigbergk da7f67c359 Refactor noisy getter methods into neat lambdas 2024-01-23 20:49:10 +01:00
Moritz Geist 2c93dd03e1 account for swapped terminations in cable object
also remove out-of-scope changes to tooltips
2024-01-10 14:29:46 +01:00
Moritz Geist ced44832f7 Remove dangling logging message used during development 2024-01-09 14:22:36 +01:00
Moritz Geist 6af3aad362 Fixes #14722, Fixes #13922: Update the CableRender
This commit updates the cable rendering logic to fix
both issue #14722 and #13922. Before, objects, terminations
and cables where drawn in the svg without context of each
other.
Now the following changes are applied:
- Hosts and Terminations are where possible sorted alphabetically
- Terminations and Cables are visually connected, and if necessary not in a vertical line
- Terminations and Hosts are visually aligning
- Cable Tooltips contain more information
2024-01-09 13:51:09 +01:00
Per von Zweigbergk c728d3c2e8 Fix formatting 2023-09-24 00:08:39 +02:00
Per von Zweigbergk 83e2c45e74 Simplify mark connected/installed implementation
Fixes: #13712 and #13806.
2023-09-23 23:45:08 +02:00
Per von Zweigbergk 27864ec865 Move DeviceInterfaceTable coloring logic into CSS
Preparatory work for simplifying toggle button code for cable status.
2023-09-23 23:07:16 +02:00
Per von Zweigbergk d44f67aea5 Add 15% alpha variants of --nbx-color
Preparatory work for factoring row styling out of Python
2023-09-23 23:01:08 +02:00
Per von Zweigbergk 41e1f24cf7 Add --nbx-color-* variables for theme colors
Preparatory work for moving row styling to CSS
2023-09-23 21:43:32 +02:00
Per von Zweigbergk d76ede17d3 Add data properties for device interface table
Preparatory work for factoring row styling decisions out of Python code.
2023-09-23 21:33:47 +02:00
44 changed files with 850 additions and 506 deletions

View File

@ -26,7 +26,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.7.5 placeholder: v3.7.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -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.7.5 placeholder: v3.7.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -61,7 +61,8 @@ django-timezone-field
# A REST API framework for Django projects # A REST API framework for Django projects
# https://www.django-rest-framework.org/community/release-notes/ # https://www.django-rest-framework.org/community/release-notes/
djangorestframework # Pinned to 3.14 for NetBox v3.7
djangorestframework<3.15
# Sane and flexible OpenAPI 3 schema generation for Django REST framework. # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
# https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst

View File

@ -2,8 +2,8 @@
{% block site_meta %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #} {# Disable search indexing unless we're building for ReadTheDocs #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %} {% if not config.extra.readthedocs %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -16,10 +16,7 @@ BASE_PATH = 'netbox/'
Default: `en-us` (US English) Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.) Defines the default preferred language/locale for requests that do not specify one. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
--- ---

View File

@ -62,7 +62,7 @@ class MyModelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned site' help_text=_('Assigned site')
) )
class Meta: class Meta:

View File

@ -3,6 +3,9 @@
!!! tip "Plugins Development Tutorial" !!! tip "Plugins Development Tutorial"
Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time! Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time!
!!! tip "Plugin Certification Program"
NetBox Labs offers a [**Plugin Certification Program**](https://github.com/netbox-community/netbox/wiki/Plugin-Certification-Program) for plugin developers interested in establishing a co-maintainer relationship. The program aims to assure ongoing compatibility, maintainability, and commercial supportability of key plugins.
NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently. NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently.
!!! info "Django Development" !!! info "Django Development"

View File

@ -1,17 +1,37 @@
# NetBox v3.7 # NetBox v3.7
## v3.7.6 (FUTURE) ## v3.7.7 (FUTURE)
### Bug Fixes
* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display
* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display
* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices
* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination
---
## v3.7.6 (2024-04-22)
### Enhancements ### Enhancements
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form
* [#15427](https://github.com/netbox-community/netbox/issues/15427) - Enable compatibility with non-Amazon S3 providers for remote data sources
* [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers * [#15640](https://github.com/netbox-community/netbox/issues/15640) - Add global search support for L2VPN identifiers
* [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS) * [#15644](https://github.com/netbox-community/netbox/issues/15644) - Introduce new configuration parameters for enabling HTTP Strict Transport Security (HSTS)
### Bug Fixes ### Bug Fixes
* [#15541](https://github.com/netbox-community/netbox/issues/15541) - Restore ability to modify assigned component template when adding/modifying an inventory item template
* [#15582](https://github.com/netbox-community/netbox/issues/15582) - Fix permission constraints for synchronization of remote data sources
* [#15588](https://github.com/netbox-community/netbox/issues/15588) - Correct OpenAPI schema definitions for read-only fields which may return null values
* [#15635](https://github.com/netbox-community/netbox/issues/15635) - Extend plugin removal instruction to include reindexing the global search cache
* [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination * [#15654](https://github.com/netbox-community/netbox/issues/15654) - Fix `AttributeError` exception when attempting to save an incomplete tunnel termination
* [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view * [#15668](https://github.com/netbox-community/netbox/issues/15668) - Fix permission required to display virtual disks tab on virtual machine UI view
* [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form * [#15685](https://github.com/netbox-community/netbox/issues/15685) - Allow filtering cables by decimal values using UI filter form
* [#15761](https://github.com/netbox-community/netbox/issues/15761) - Add missing `ike_policy` & `ike_policy_id` filters for IKE proposals
* [#15771](https://github.com/netbox-community/netbox/issues/15771) - Include `id` in list of supported fields for all bulk import forms
* [#15790](https://github.com/netbox-community/netbox/issues/15790) - Fix live preview support for EventRule comments
--- ---

View File

@ -42,6 +42,7 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox

View File

@ -257,3 +257,14 @@ class NetBoxAutoSchema(AutoSchema):
if '{id}' in self.path: if '{id}' in self.path:
return f"{self.method.capitalize()} a {model_name} object." return f"{self.method.capitalize()} a {model_name} object."
return f"{self.method.capitalize()} a list of {model_name} objects." return f"{self.method.capitalize()} a list of {model_name} objects."
class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.SerializedPKRelatedField'
def map_serializer_field(self, auto_schema, direction):
if direction == "response":
component = auto_schema.resolve_serializer(self.target.serializer, direction)
return component.ref if component else None
else:
return build_basic_type(OpenApiTypes.INT)

View File

@ -3,6 +3,7 @@ import json
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms.fields import JSONField as _JSONField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
@ -12,7 +13,7 @@ from netbox.forms import NetBoxModelForm
from netbox.registry import registry from netbox.registry import registry
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from utilities.forms import BootstrapMixin, get_field_value from utilities.forms import BootstrapMixin, get_field_value
from utilities.forms.fields import CommentField from utilities.forms.fields import CommentField, JSONField
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
__all__ = ( __all__ = (
@ -132,6 +133,9 @@ class ConfigFormMetaclass(forms.models.ModelFormMetaclass):
'help_text': param.description, 'help_text': param.description,
} }
field_kwargs.update(**param.field_kwargs) field_kwargs.update(**param.field_kwargs)
if param.field is _JSONField:
# Replace with our own JSONField to get pretty JSON in config editor
param.field = JSONField
param_fields[param.name] = param.field(**field_kwargs) param_fields[param.name] = param.field(**field_kwargs)
attrs.update(param_fields) attrs.update(param_fields)

View File

@ -612,7 +612,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
component = serializers.SerializerMethodField(read_only=True) component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
@ -668,7 +668,7 @@ class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
role = NestedDeviceRoleSerializer() role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.') device_role = NestedDeviceRoleSerializer(read_only=True, help_text=_('Deprecated in v3.6 in favor of `role`.'))
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
@ -685,7 +685,7 @@ class DeviceSerializer(NetBoxModelSerializer):
) )
status = ChoiceField(choices=DeviceStatusChoices, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True) oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
@ -735,7 +735,7 @@ class DeviceSerializer(NetBoxModelSerializer):
class DeviceWithConfigContextSerializer(DeviceSerializer): class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True) config_context = serializers.SerializerMethodField(read_only=True, allow_null=True)
class Meta(DeviceSerializer.Meta): class Meta(DeviceSerializer.Meta):
fields = [ fields = [
@ -1067,7 +1067,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
required=False, required=False,
allow_null=True allow_null=True
) )
component = serializers.SerializerMethodField(read_only=True) component = serializers.SerializerMethodField(read_only=True, allow_null=True)
_depth = serializers.IntegerField(source='level', read_only=True) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:

View File

@ -1373,14 +1373,14 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
label=_('Device'), label=_('Device'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Assigned role' help_text=_('Assigned role')
) )
tenant = CSVModelChoiceField( tenant = CSVModelChoiceField(
label=_('Tenant'), label=_('Tenant'),
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text='Assigned tenant' help_text=_('Assigned tenant')
) )
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'), label=_('Status'),

View File

@ -976,21 +976,67 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
component_type = ContentTypeChoiceField( # Assigned component selectors
queryset=ContentType.objects.all(), consoleporttemplate = DynamicModelChoiceField(
limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, queryset=ConsolePortTemplate.objects.all(),
required=False, required=False,
widget=forms.HiddenInput query_params={
'device_type_id': '$device_type'
},
label=_('Console port template')
) )
component_id = forms.IntegerField( consoleserverporttemplate = DynamicModelChoiceField(
queryset=ConsoleServerPortTemplate.objects.all(),
required=False, required=False,
widget=forms.HiddenInput query_params={
'device_type_id': '$device_type'
},
label=_('Console server port template')
)
frontporttemplate = DynamicModelChoiceField(
queryset=FrontPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Front port template')
)
interfacetemplate = DynamicModelChoiceField(
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Interface template')
)
poweroutlettemplate = DynamicModelChoiceField(
queryset=PowerOutletTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power outlet template')
)
powerporttemplate = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Power port template')
)
rearporttemplate = DynamicModelChoiceField(
queryset=RearPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type'
},
label=_('Rear port template')
) )
fieldsets = ( fieldsets = (
(None, ( (None, (
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
)), )),
) )
@ -998,9 +1044,52 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
model = InventoryItemTemplate model = InventoryItemTemplate
fields = [ fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
] ]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selection
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate',
'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError(_("An InventoryItem can only be assigned to a single component."))
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
# #
# Device components # Device components

View File

@ -8,17 +8,16 @@ from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color from utilities.utils import foreground_color
__all__ = ( __all__ = (
'CableTraceSVG', 'CableTraceSVG',
) )
OFFSET = 0.5 OFFSET = 0.5
PADDING = 10 PADDING = 10
LINE_HEIGHT = 20 LINE_HEIGHT = 20
FANOUT_HEIGHT = 35 FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15 FANOUT_LEG_HEIGHT = 15
CABLE_HEIGHT = 4 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
class Node(Hyperlink): class Node(Hyperlink):
@ -84,31 +83,38 @@ class Connector(Group):
labels: Iterable of text labels labels: Iterable of text labels
""" """
def __init__(self, start, url, color, labels=[], description=[], **extra): def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
super().__init__(class_='connector', **extra) super().__init__(class_="connector", **extra)
self.start = start self.start = start
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
self.end = (start[0], start[1] + self.height) # Allow to specify end-position or auto-calculate
self.end = end if end else (start[0], start[1] + self.height)
self.color = color or '000000' self.color = color or '000000'
# Draw a "shadow" line to give the cable a border if wireless:
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') # Draw the cable
self.add(cable_shadow) cable = Line(start=self.start, end=self.end, class_="wireless-link")
self.add(cable)
else:
# Draw a "shadow" line to give the cable a border
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
self.add(cable_shadow)
# Draw the cable # Draw the cable
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
self.add(cable) self.add(cable)
# Add link # Add link
link = Hyperlink(href=url, target='_parent') link = Hyperlink(href=url, target='_parent')
# Add text label(s) # Add text label(s)
cursor = start[1] cursor = start[1] + text_offset
cursor += PADDING * 2 cursor += PADDING * 2 + LINE_HEIGHT * 2
x_coord = (start[0] + end[0]) / 2 + PADDING
for i, label in enumerate(labels): for i, label in enumerate(labels):
cursor += LINE_HEIGHT cursor += LINE_HEIGHT
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) text_coords = (x_coord, cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else []) text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text) link.add(text)
if len(description) > 0: if len(description) > 0:
@ -190,8 +196,9 @@ class CableTraceSVG:
def draw_parent_objects(self, obj_list): def draw_parent_objects(self, obj_list):
""" """
Draw a set of parent objects. Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
""" """
objects = []
width = self.width / len(obj_list) width = self.width / len(obj_list)
for i, obj in enumerate(obj_list): for i, obj in enumerate(obj_list):
node = Node( node = Node(
@ -199,23 +206,26 @@ class CableTraceSVG:
width=width, width=width,
url=f'{self.base_url}{obj.get_absolute_url()}', url=f'{self.base_url}{obj.get_absolute_url()}',
color=self._get_color(obj), color=self._get_color(obj),
labels=self._get_labels(obj) labels=self._get_labels(obj),
object=obj
) )
objects.append(node)
self.parent_objects.append(node) self.parent_objects.append(node)
if i + 1 == len(obj_list): if i + 1 == len(obj_list):
self.cursor += node.box['height'] self.cursor += node.box['height']
return objects
def draw_terminations(self, terminations): def draw_object_terminations(self, terminations, offset_x, width):
""" """
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable. Draw all terminations belonging to an object with specified offset and width
Return all created nodes and their maximum height
""" """
nodes = []
nodes_height = 0 nodes_height = 0
width = self.width / len(terminations) nodes = []
# Sort them by name to make renders more readable
for i, term in enumerate(terminations): for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
node = Node( node = Node(
position=(i * width, self.cursor), position=(offset_x + i * width, self.cursor),
width=width, width=width,
url=f'{self.base_url}{term.get_absolute_url()}', url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term), color=self._get_color(term),
@ -225,133 +235,89 @@ class CableTraceSVG:
) )
nodes_height = max(nodes_height, node.box['height']) nodes_height = max(nodes_height, node.box['height'])
nodes.append(node) nodes.append(node)
return nodes, nodes_height
def draw_terminations(self, terminations, parent_object_nodes):
"""
Draw a row of terminating objects (e.g. interfaces) and return all created nodes
Attach them to previously created parent objects
"""
nodes = []
nodes_height = 0
# Draw terminations for each parent object
for parent in parent_object_nodes:
parent_terms = [term for term in terminations if term.parent_object == parent.object]
# Width and offset(position) for each termination box
width = parent.box['width'] / len(parent_terms)
offset_x = parent.box['x']
result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
nodes.extend(result)
self.cursor += nodes_height self.cursor += nodes_height
self.terminations.extend(nodes) self.terminations.extend(nodes)
return nodes return nodes
def draw_fanin(self, node, connector): def draw_far_objects(self, obj_list, terminations):
points = (
node.bottom_center,
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
connector.start,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_fanout(self, node, connector):
points = (
connector.end,
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
node.top_center,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{connector.color}'),
))
def draw_cable(self, cable, terminations, cable_count=0):
""" """
Draw a single cable. Terminations and cable count are passed for determining position and padding Draw the far-end objects and its terminations and return all created nodes
:param cable: The cable to draw
:param terminations: List of terminations to build positioning data off of
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
tooltip.
""" """
# Make sure elements are sorted by name for readability
objects = sorted(obj_list, key=lambda x: x.name)
width = self.width / len(objects)
# If the cable count is higher than 2, collapse the description into a tooltip # Max-height of created terminations
if cable_count > 2: terms_height = 0
# Use the cable __str__ function to denote the cable term_nodes = []
labels = [f'{cable}']
# Include the label and the status description in the tooltip # Draw the terminations by per object first
description = [ for i, obj in enumerate(objects):
f'Cable {cable}', obj_terms = [term for term in terminations if term.parent_object == obj]
cable.get_status_display() obj_pos = i * width
] result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
if cable.type: terms_height = max(terms_height, result_nodes_height)
# Include the cable type in the tooltip term_nodes.extend(result)
description.append(cable.get_type_display())
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
labels = [
f'Cable {cable}',
cable.get_status_display()
]
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
# If there is only one termination, center on that termination # Update cursor and draw the objects
# Otherwise average the center across the terminations self.cursor += terms_height
if len(terminations) == 1: self.terminations.extend(term_nodes)
center = terminations[0].bottom_center[0] object_nodes = self.draw_parent_objects(objects)
else:
# Get a list of termination centers
termination_centers = [term.bottom_center[0] for term in terminations]
# Average the centers
center = sum(termination_centers) / len(termination_centers)
# Create the connector return object_nodes, term_nodes
connector = Connector(
start=(center, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels,
description=description
)
# Set the cursor position def draw_fanin(self, target, terminations, color):
self.cursor += connector.height
return connector
def draw_wirelesslink(self, wirelesslink):
""" """
Draw a line with labels representing a WirelessLink. Draw the fan-in-lines from each of the terminations to the targetpoint
""" """
group = Group(class_='connector') for term in terminations:
points = (
term.bottom_center,
(term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
target,
)
self.connectors.extend((
Polyline(points=points, class_='cable-shadow'),
Polyline(points=points, style=f'stroke: #{color}'),
))
labels = [ def draw_fanout(self, start, terminations, color):
f'Wireless link {wirelesslink}', """
wirelesslink.get_status_display() Draw the fan-out-lines from the startpoint to each of the terminations
] """
if wirelesslink.ssid: for term in terminations:
labels.append(wirelesslink.ssid) points = (
term.top_center,
# Draw the wireless link (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
start = (OFFSET + self.center, self.cursor) start,
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 )
end = (start[0], start[1] + height) self.connectors.extend((
line = Line(start=start, end=end, class_='wireless-link') Polyline(points=points, class_='cable-shadow'),
group.add(line) Polyline(points=points, style=f'stroke: #{color}'),
))
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
# Add text label(s)
for i, label in enumerate(labels):
self.cursor += LINE_HEIGHT
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text)
group.add(link)
self.cursor += PADDING * 2
return group
def draw_attachment(self): def draw_attachment(self):
""" """
@ -378,86 +344,99 @@ class CableTraceSVG:
traced_path = self.origin.trace() traced_path = self.origin.trace()
parent_object_nodes = []
# Iterate through each (terms, cable, terms) segment in the path # Iterate through each (terms, cable, terms) segment in the path
for i, segment in enumerate(traced_path): for i, segment in enumerate(traced_path):
near_ends, links, far_ends = segment near_ends, links, far_ends = segment
# Near end parent # This is segment number one.
if i == 0: if i == 0:
# If this is the first segment, draw the originating termination's parent object # If this is the first segment, draw the originating termination's parent object
self.draw_parent_objects(set(end.parent_object for end in near_ends)) parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
# Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
# Near end termination(s) near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
terminations = self.draw_terminations(near_ends) self.cursor += CABLE_HEIGHT
# Connector (a Cable or WirelessLink) # Connector (a Cable or WirelessLink)
if links: if links:
link_cables = {}
fanin = False
fanout = False
# Determine if we have fanins or fanouts parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
if len(near_ends) > len(set(links)): for cable in links:
self.cursor += FANOUT_HEIGHT # Fill in labels and description with all available data
fanin = True description = [
if len(far_ends) > len(set(links)): f"Link {cable}",
fanout = True cable.get_status_display()
cursor = self.cursor ]
for link in links: near = []
# Cable far = []
if type(link) is Cable and not link_cables.get(link.pk): color = '000000'
# Reset cursor if cable.description:
self.cursor = cursor description.append(f"{cable.description}")
# Generate a list of terminations connected to this cable if isinstance(cable, Cable):
near_end_link_terminations = [term for term in terminations if term.object.cable == link] labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
# Draw the cable if cable.type:
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) description.append(cable.get_type_display())
# Add cable to the list of cables if cable.length and cable.length_unit:
link_cables.update({link.pk: cable}) description.append(f"{cable.length} {cable.get_length_unit_display()}")
# Add cable to drawing color = cable.color or '000000'
self.connectors.append(cable)
# Draw fan-ins # Collect all connected nodes to this cable
if len(near_ends) > 1 and fanin: near = [term for term in near_terminations if term.object in cable.a_terminations]
for term in terminations: far = [term for term in far_terminations if term.object in cable.b_terminations]
if term.object.cable == link: if not (near and far):
self.draw_fanin(term, cable) # a and b terminations may be swapped
near = [term for term in near_terminations if term.object in cable.b_terminations]
far = [term for term in far_terminations if term.object in cable.a_terminations]
elif isinstance(cable, WirelessLink):
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid:
description.append(f"{cable.ssid}")
near = [term for term in near_terminations if term.object == cable.interface_a]
far = [term for term in far_terminations if term.object == cable.interface_b]
if not (near and far):
# a and b terminations may be swapped
near = [term for term in near_terminations if term.object == cable.interface_b]
far = [term for term in far_terminations if term.object == cable.interface_a]
# WirelessLink # Select most-probable start and end position
elif type(link) is WirelessLink: start = near[0].bottom_center
wirelesslink = self.draw_wirelesslink(link) end = far[0].top_center
self.connectors.append(wirelesslink) text_offset = 0
# Far end termination(s) if len(near) > 1:
if len(far_ends) > 1: # Handle Fan-In - change start position to be directly below start
if fanout: start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
self.cursor += FANOUT_HEIGHT self.draw_fanin(start, near, color)
terminations = self.draw_terminations(far_ends) text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
for term in terminations: elif len(far) > 1:
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): # Handle Fan-Out - change end position to be directly above end
self.draw_fanout(term, link_cables.get(term.object.cable.pk)) end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
else: self.draw_fanout(end, far, color)
self.draw_terminations(far_ends) text_offset -= FANOUT_HEIGHT
elif far_ends:
self.draw_terminations(far_ends)
else:
# Link is not connected to anything
break
# Far end parent # Create the connector
parent_objects = set(end.parent_object for end in far_ends) connector = Connector(
self.draw_parent_objects(parent_objects) start=start,
end=end,
color=color,
wireless=isinstance(cable, WirelessLink),
url=f'{self.base_url}{cable.get_absolute_url()}',
text_offset=text_offset,
labels=labels,
description=description
)
self.connectors.append(connector)
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
# a CircuitTermination) # a CircuitTermination)
elif far_ends: elif far_ends:
# Attachment # Attachment
attachment = self.draw_attachment() attachment = self.draw_attachment()
self.connectors.append(attachment) self.connectors.append(attachment)
# Object # Object
self.draw_parent_objects(far_ends) parent_object_nodes = self.draw_parent_objects(far_ends)
# Determine drawing size # Determine drawing size
self.drawing = svgwrite.Drawing( self.drawing = svgwrite.Drawing(

View File

@ -51,34 +51,6 @@ def get_cabletermination_row_class(record):
return '' return ''
def get_interface_row_class(record):
if not record.enabled:
return 'danger'
elif record.is_virtual:
return 'primary'
return get_cabletermination_row_class(record)
def get_interface_state_attribute(record):
"""
Get interface enabled state as string to attach to <tr/> DOM element.
"""
if record.enabled:
return 'enabled'
else:
return 'disabled'
def get_interface_connected_attribute(record):
"""
Get interface disconnected state as string to attach to <tr/> DOM element.
"""
if record.mark_connected or record.cable:
return 'connected'
else:
return 'disconnected'
# #
# Device roles # Device roles
# #
@ -706,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable):
'cable', 'connection', 'cable', 'connection',
) )
row_attrs = { row_attrs = {
'class': get_interface_row_class,
'data-name': lambda record: record.name, 'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute, 'data-enabled': lambda record: "enabled" if record.enabled else "disabled",
'data-type': lambda record: record.type, 'data-virtual': lambda record: "true" if record.is_virtual else "false",
'data-connected': get_interface_connected_attribute 'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
'data-cable-status': lambda record: record.cable.status if record.cable else "",
'data-type': lambda record: record.type
} }

View File

@ -1656,6 +1656,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateCreateForm form = forms.InventoryItemTemplateCreateForm
model_form = forms.InventoryItemTemplateForm model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
def alter_object(self, instance, request): def alter_object(self, instance, request):
# Set component (if any) # Set component (if any)
@ -1673,6 +1674,7 @@ class InventoryItemTemplateCreateView(generic.ComponentCreateView):
class InventoryItemTemplateEditView(generic.ObjectEditView): class InventoryItemTemplateEditView(generic.ObjectEditView):
queryset = InventoryItemTemplate.objects.all() queryset = InventoryItemTemplate.objects.all()
form = forms.InventoryItemTemplateForm form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_edit.html'
@register_model_view(InventoryItemTemplate, 'delete') @register_model_view(InventoryItemTemplate, 'delete')

View File

@ -265,6 +265,7 @@ class EventRuleForm(NetBoxModelForm):
required=False, required=False,
help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.') help_text=_('Enter parameters to pass to the action in <a href="https://json.org/">JSON</a> format.')
) )
comments = CommentField()
fieldsets = ( fieldsets = (
(_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')), (_('Event Rule'), ('name', 'description', 'content_types', 'enabled', 'tags')),

View File

@ -262,7 +262,7 @@ class AvailableVLANSerializer(serializers.Serializer):
Representation of a VLAN which does not exist in the database. Representation of a VLAN which does not exist in the database.
""" """
vid = serializers.IntegerField(read_only=True) vid = serializers.IntegerField(read_only=True)
group = NestedVLANGroupSerializer(read_only=True) group = NestedVLANGroupSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
return { return {
@ -348,9 +348,9 @@ class AvailablePrefixSerializer(serializers.Serializer):
""" """
Representation of a prefix which does not exist in the database. Representation of a prefix which does not exist in the database.
""" """
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True, allow_null=True)
prefix = serializers.CharField(read_only=True) prefix = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
def to_representation(self, instance): def to_representation(self, instance):
if self.context.get('vrf'): if self.context.get('vrf'):
@ -429,9 +429,9 @@ class AvailableIPSerializer(serializers.Serializer):
""" """
Representation of an IP address which does not exist in the database. Representation of an IP address which does not exist in the database.
""" """
family = serializers.IntegerField(read_only=True) family = serializers.IntegerField(read_only=True, allow_null=True)
address = serializers.CharField(read_only=True) address = serializers.CharField(read_only=True)
vrf = NestedVRFSerializer(read_only=True) vrf = NestedVRFSerializer(read_only=True, allow_null=True)
description = serializers.CharField(required=False) description = serializers.CharField(required=False)
def to_representation(self, instance): def to_representation(self, instance):

View File

@ -73,17 +73,12 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
""" """
Base form for creating a NetBox objects from CSV data. Used for bulk importing. Base form for creating a NetBox objects from CSV data. Used for bulk importing.
""" """
id = forms.IntegerField(
label=_('Id'),
required=False,
help_text='Numeric ID of an existing object to update (if not creating a new object)'
)
tags = CSVModelMultipleChoiceField( tags = CSVModelMultipleChoiceField(
label=_('Tags'), label=_('Tags'),
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False, required=False,
to_field_name='slug', to_field_name='slug',
help_text='Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")' help_text=_('Tag slugs separated by commas, encased with double quotes (e.g. "tag1,tag2,tag3")')
) )
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):

View File

@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup # Environment setup
# #
VERSION = '3.7.6-dev' VERSION = '3.7.7-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,10 +7,10 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
* *
* @param element Connection Toggle Button Element * @param element Connection Toggle Button Element
*/ */
function toggleConnection(element: HTMLButtonElement): void { function setConnectionStatus(element: HTMLButtonElement, status: string): void {
// Get the button's row to change its data-cable-status attribute
const row = element.parentElement?.parentElement as HTMLTableRowElement;
const url = element.getAttribute('data-url'); const url = element.getAttribute('data-url');
const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected';
if (isTruthy(url)) { if (isTruthy(url)) {
apiPatch(url, { status }).then(res => { apiPatch(url, { status }).then(res => {
@ -19,34 +19,18 @@ function toggleConnection(element: HTMLButtonElement): void {
createToast('danger', 'Error', res.error).show(); createToast('danger', 'Error', res.error).show();
return; return;
} else { } else {
// Get the button's row to change its styles. // Update cable status in DOM
const row = element.parentElement?.parentElement as HTMLTableRowElement; row.setAttribute('data-cable-status', status);
// Get the button's icon to change its CSS class.
const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement;
if (connected) {
row.classList.remove('success');
row.classList.add('info');
element.classList.remove('connected', 'btn-warning');
element.classList.add('btn-info');
element.title = 'Mark Installed';
icon.classList.remove('mdi-lan-disconnect');
icon.classList.add('mdi-lan-connect');
} else {
row.classList.remove('info');
row.classList.add('success');
element.classList.remove('btn-success');
element.classList.add('connected', 'btn-warning');
element.title = 'Mark Installed';
icon.classList.remove('mdi-lan-connect');
icon.classList.add('mdi-lan-disconnect');
}
} }
}); });
} }
} }
export function initConnectionToggle(): void { export function initConnectionToggle(): void {
for (const element of getElements<HTMLButtonElement>('button.cable-toggle')) { for (const element of getElements<HTMLButtonElement>('button.mark-planned')) {
element.addEventListener('click', () => toggleConnection(element)); element.addEventListener('click', () => setConnectionStatus(element, 'planned'));
}
for (const element of getElements<HTMLButtonElement>('button.mark-installed')) {
element.addEventListener('click', () => setConnectionStatus(element, 'connected'));
} }
} }

View File

@ -1075,4 +1075,41 @@ html {
display: none; display: none;
} }
} }
}
// Apply row colours to interface lists
&[data-netbox-url-name='device_interfaces'] {
tr[data-cable-status=connected] {
background-color: rgba(map.get($theme-colors, "green"), 0.15);
}
tr[data-cable-status=planned] {
background-color: rgba(map.get($theme-colors, "blue"), 0.15);
}
tr[data-cable-status=decommissioning] {
background-color: rgba(map.get($theme-colors, "yellow"), 0.15);
}
tr[data-mark-connected=true] {
background-color: rgba(map.get($theme-colors, "success"), 0.15);
}
tr[data-virtual=true] {
background-color: rgba(map.get($theme-colors, "primary"), 0.15);
}
tr[data-enabled=disabled] {
background-color: rgba(map.get($theme-colors, "danger"), 0.15);
}
// Only show the correct button depending on the cable status
tr[data-cable-status=connected] button.mark-installed {
display: none;
}
tr:not([data-cable-status=connected]) button.mark-planned {
display: none;
}
}
}

View File

@ -1,12 +1,9 @@
{% load i18n %} {% load i18n %}
{% if perms.dcim.change_cable %} {% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %} <button type="button" class="btn btn-warning btn-sm mark-planned" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}"> <i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i> </button>
</button> <button type="button" class="btn btn-info btn-sm mark-installed" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
{% else %} <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
<button type="button" class="btn btn-info btn-sm cable-toggle" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}"> </button>
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button>
{% endif %}
{% endif %} {% endif %}

View File

@ -0,0 +1,104 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% load i18n %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Inventory Item" %}</h5>
</div>
{% render_field form.device_type %}
{% render_field form.parent %}
{% render_field form.name %}
{% render_field form.label %}
{% render_field form.role %}
{% render_field form.description %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Hardware" %}</h5>
</div>
{% render_field form.manufacturer %}
{% render_field form.part_id %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Component Assignment" %}</h5>
</div>
<div class="row mb-2 offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}">
{% trans "Console Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverporttemplate %}active{% endif %}">
{% trans "Console Server Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontporttemplate %}active{% endif %}">
{% trans "Front Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interfacetemplate %}active{% endif %}">
{% trans "Interface" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlettemplate %}active{% endif %}">
{% trans "Power Outlet" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerporttemplate %}active{% endif %}">
{% trans "Power Port" %}
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearporttemplate %}active{% endif %}">
{% trans "Rear Port" %}
</button>
</li>
</ul>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if form.initial.consoleporttemplate or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
{% render_field form.consoleporttemplate %}
</div>
<div class="tab-pane {% if form.initial.consoleserverporttemplate %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
{% render_field form.consoleserverporttemplate %}
</div>
<div class="tab-pane {% if form.initial.frontporttemplate %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
{% render_field form.frontporttemplate %}
</div>
<div class="tab-pane {% if form.initial.interfacetemplate %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
{% render_field form.interfacetemplate %}
</div>
<div class="tab-pane {% if form.initial.poweroutlettemplate %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
{% render_field form.poweroutlettemplate %}
</div>
<div class="tab-pane {% if form.initial.powerporttemplate %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
{% render_field form.powerporttemplate %}
</div>
<div class="tab-pane {% if form.initial.rearporttemplate %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
{% render_field form.rearporttemplate %}
</div>
</div>
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
# Translators: # Translators:
# Jonathan Senecal, 2024 # Jonathan Senecal, 2024
# Jeremy Stretch, 2024 # Jeremy Stretch, 2024
# Quentin Laurent, 2024
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
@ -14,7 +15,7 @@ msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-04 19:11+0000\n" "POT-Creation-Date: 2024-04-04 19:11+0000\n"
"PO-Revision-Date: 2023-10-30 17:48+0000\n" "PO-Revision-Date: 2023-10-30 17:48+0000\n"
"Last-Translator: Jeremy Stretch, 2024\n" "Last-Translator: Quentin Laurent, 2024\n"
"Language-Team: French (https://app.transifex.com/netbox-community/teams/178115/fr/)\n" "Language-Team: French (https://app.transifex.com/netbox-community/teams/178115/fr/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -3716,7 +3717,7 @@ msgstr "Réservation"
#: dcim/forms/model_forms.py:301 dcim/forms/model_forms.py:384 #: dcim/forms/model_forms.py:301 dcim/forms/model_forms.py:384
#: utilities/forms/fields/fields.py:47 #: utilities/forms/fields/fields.py:47
msgid "Slug" msgid "Slug"
msgstr "limace" msgstr "Identifiant"
#: dcim/forms/model_forms.py:308 templates/dcim/devicetype.html:12 #: dcim/forms/model_forms.py:308 templates/dcim/devicetype.html:12
msgid "Chassis" msgid "Chassis"
@ -5813,7 +5814,7 @@ msgstr "Poids maximum"
#: ipam/tables/asn.py:66 netbox/navigation/menu.py:16 #: ipam/tables/asn.py:66 netbox/navigation/menu.py:16
#: netbox/navigation/menu.py:18 #: netbox/navigation/menu.py:18
msgid "Sites" msgid "Sites"
msgstr "Des sites" msgstr "Sites"
#: dcim/tests/test_api.py:49 #: dcim/tests/test_api.py:49
msgid "Test case must set peer_termination_type" msgid "Test case must set peer_termination_type"
@ -13355,7 +13356,7 @@ msgstr ""
#: utilities/forms/fields/fields.py:48 #: utilities/forms/fields/fields.py:48
msgid "URL-friendly unique shorthand" msgid "URL-friendly unique shorthand"
msgstr "Raccourci unique et convivial pour les URL" msgstr "Identifiant unique utilisable dans les URL"
#: utilities/forms/fields/fields.py:101 #: utilities/forms/fields/fields.py:101
msgid "Enter context data in <a href=\"https://json.org/\">JSON</a> format." msgid "Enter context data in <a href=\"https://json.org/\">JSON</a> format."

View File

@ -5,8 +5,8 @@
# #
# Translators: # Translators:
# Tatsuya Ueda <ml@tatsuya.info>, 2024 # Tatsuya Ueda <ml@tatsuya.info>, 2024
# teapot, 2024
# Jeremy Stretch, 2024 # Jeremy Stretch, 2024
# teapot, 2024
# #
#, fuzzy #, fuzzy
msgid "" msgid ""
@ -15,7 +15,7 @@ msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-04 19:11+0000\n" "POT-Creation-Date: 2024-04-04 19:11+0000\n"
"PO-Revision-Date: 2023-10-30 17:48+0000\n" "PO-Revision-Date: 2023-10-30 17:48+0000\n"
"Last-Translator: Jeremy Stretch, 2024\n" "Last-Translator: teapot, 2024\n"
"Language-Team: Japanese (https://app.transifex.com/netbox-community/teams/178115/ja/)\n" "Language-Team: Japanese (https://app.transifex.com/netbox-community/teams/178115/ja/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -7681,7 +7681,7 @@ msgstr "プレフィックス内およびプレフィックスを含む"
#: ipam/filtersets.py:259 #: ipam/filtersets.py:259
msgid "Prefixes which contain this prefix or IP" msgid "Prefixes which contain this prefix or IP"
msgstr "このプレフィックスまたは IP を含むプレフィックス" msgstr "このプレフィックス / IP を含むプレフィックス"
#: ipam/filtersets.py:270 ipam/filtersets.py:538 ipam/forms/bulk_edit.py:326 #: ipam/filtersets.py:270 ipam/filtersets.py:538 ipam/forms/bulk_edit.py:326
#: ipam/forms/filtersets.py:191 ipam/forms/filtersets.py:317 #: ipam/forms/filtersets.py:191 ipam/forms/filtersets.py:317
@ -7700,11 +7700,11 @@ msgstr "VLAN 番号 (1-4094)"
#: ipam/forms/model_forms.py:430 templates/tenancy/contact.html:54 #: ipam/forms/model_forms.py:430 templates/tenancy/contact.html:54
#: tenancy/forms/bulk_edit.py:112 #: tenancy/forms/bulk_edit.py:112
msgid "Address" msgid "Address"
msgstr "住所" msgstr "アドレス"
#: ipam/filtersets.py:445 #: ipam/filtersets.py:445
msgid "Ranges which contain this prefix or IP" msgid "Ranges which contain this prefix or IP"
msgstr "このプレフィックスまたは IP を含む範囲" msgstr "このプレフィックス / IP を含む範囲"
#: ipam/filtersets.py:473 ipam/filtersets.py:529 #: ipam/filtersets.py:473 ipam/filtersets.py:529
msgid "Parent prefix" msgid "Parent prefix"
@ -7743,11 +7743,11 @@ msgstr "FHRP グループ (ID)"
#: ipam/filtersets.py:618 #: ipam/filtersets.py:618
msgid "Is assigned to an interface" msgid "Is assigned to an interface"
msgstr "インタフェースに割り当てられている" msgstr "インタフェースに割り当てられている"
#: ipam/filtersets.py:622 #: ipam/filtersets.py:622
msgid "Is assigned" msgid "Is assigned"
msgstr "割り当てられている" msgstr "割当済みか"
#: ipam/filtersets.py:1047 #: ipam/filtersets.py:1047
msgid "IP address (ID)" msgid "IP address (ID)"
@ -7881,7 +7881,7 @@ msgstr "子 VLAN VID の最小値"
#: ipam/forms/bulk_edit.py:420 #: ipam/forms/bulk_edit.py:420
msgid "Maximum child VLAN VID" msgid "Maximum child VLAN VID"
msgstr "子 VLAN VID の最大" msgstr "子 VLAN VID の最大"
#: ipam/forms/bulk_edit.py:428 ipam/forms/model_forms.py:531 #: ipam/forms/bulk_edit.py:428 ipam/forms/model_forms.py:531
msgid "Scope type" msgid "Scope type"
@ -7905,11 +7905,11 @@ msgstr "ポート"
#: ipam/forms/bulk_import.py:47 #: ipam/forms/bulk_import.py:47
msgid "Import route targets" msgid "Import route targets"
msgstr "ルートターゲットをインポート" msgstr "インポートルートターゲット"
#: ipam/forms/bulk_import.py:53 #: ipam/forms/bulk_import.py:53
msgid "Export route targets" msgid "Export route targets"
msgstr "ルートターゲットをエクスポートする" msgstr "エクスポートルートターゲット"
#: ipam/forms/bulk_import.py:91 ipam/forms/bulk_import.py:111 #: ipam/forms/bulk_import.py:91 ipam/forms/bulk_import.py:111
#: ipam/forms/bulk_import.py:131 #: ipam/forms/bulk_import.py:131

View File

@ -70,6 +70,12 @@ class CSVModelForm(forms.ModelForm):
""" """
ModelForm used for the import of objects in CSV format. ModelForm used for the import of objects in CSV format.
""" """
id = forms.IntegerField(
label=_('ID'),
required=False,
help_text=_('Numeric ID of an existing object to update (if not creating a new object)')
)
def __init__(self, *args, headers=None, **kwargs): def __init__(self, *args, headers=None, **kwargs):
self.headers = headers or {} self.headers = headers or {}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -10,10 +10,11 @@ from django.test import Client, TestCase as _TestCase
from netaddr import IPNetwork from netaddr import IPNetwork
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from netbox.models.features import CustomFieldsMixin
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.permissions import resolve_permission_ct from utilities.permissions import resolve_permission_ct
from utilities.utils import content_type_identifier from utilities.utils import content_type_identifier
from .utils import extract_form_failures from .utils import DUMMY_CF_DATA, extract_form_failures
__all__ = ( __all__ = (
'ModelTestCase', 'ModelTestCase',
@ -166,8 +167,12 @@ class ModelTestCase(TestCase):
model_dict = self.model_to_dict(instance, fields=fields, api=api) model_dict = self.model_to_dict(instance, fields=fields, api=api)
# Omit any dictionary keys which are not instance attributes or have been excluded # Omit any dictionary keys which are not instance attributes or have been excluded
relevant_data = { model_data = {
k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude
} }
self.assertDictEqual(model_dict, relevant_data) self.assertDictEqual(model_dict, model_data)
# Validate any custom field data, if present
if getattr(instance, 'custom_field_data', None):
self.assertDictEqual(instance.custom_field_data, DUMMY_CF_DATA)

View File

@ -1,13 +1,16 @@
import json
import logging import logging
import re import re
from contextlib import contextmanager from contextlib import contextmanager
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify from django.utils.text import slugify
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.models import Tag from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, Tag
from virtualization.models import Cluster, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterType, VirtualMachine
@ -102,3 +105,42 @@ def disable_warnings(logger_name):
logger.setLevel(logging.ERROR) logger.setLevel(logging.ERROR)
yield yield
logger.setLevel(current_level) logger.setLevel(current_level)
#
# Custom field testing
#
DUMMY_CF_DATA = {
'text_field': 'foo123',
'integer_field': 456,
'decimal_field': 456.12,
'boolean_field': True,
'json_field': {'abc': 123},
}
def add_custom_field_data(form_data, model):
"""
Create some custom fields for the model and add a value for each to the form data.
Args:
form_data: The dictionary of form data to be updated
model: The model of the object the form seeks to create or modify
"""
content_type = ContentType.objects.get_for_model(model)
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
)
CustomField.objects.bulk_create(custom_fields)
for cf in custom_fields:
cf.content_types.set([content_type])
form_data.update({
f'cf_{k}': v if type(v) is str else json.dumps(v)
for k, v in DUMMY_CF_DATA.items()
})

View File

@ -10,11 +10,11 @@ from django.utils.translation import gettext as _
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import ObjectChange from extras.models import ObjectChange
from netbox.models.features import ChangeLoggingMixin from netbox.models.features import ChangeLoggingMixin, CustomFieldsMixin
from users.models import ObjectPermission from users.models import ObjectPermission
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from .base import ModelTestCase from .base import ModelTestCase
from .utils import disable_warnings, post_data from .utils import add_custom_field_data, disable_warnings, post_data
__all__ = ( __all__ = (
'ModelViewTestCase', 'ModelViewTestCase',
@ -26,7 +26,6 @@ __all__ = (
# UI Tests # UI Tests
# #
class ModelViewTestCase(ModelTestCase): class ModelViewTestCase(ModelTestCase):
""" """
Base TestCase for model views. Subclass to test individual views. Base TestCase for model views. Subclass to test individual views.
@ -166,6 +165,10 @@ class ViewTestCases:
# Try GET with model-level permission # Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200) self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission # Try POST with model-level permission
initial_count = self._get_queryset().count() initial_count = self._get_queryset().count()
request = { request = {
@ -265,6 +268,10 @@ class ViewTestCases:
# Try GET with model-level permission # Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200) self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
# Add custom field data if the model supports it
if issubclass(self.model, CustomFieldsMixin):
add_custom_field_data(self.form_data, self.model)
# Try POST with model-level permission # Try POST with model-level permission
request = { request = {
'path': self._get_url('edit', instance), 'path': self._get_url('edit', instance),

View File

@ -76,7 +76,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
role = NestedDeviceRoleSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True) primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None) config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)

View File

@ -136,6 +136,17 @@ class IKEProposalFilterSet(NetBoxModelFilterSet):
group = django_filters.MultipleChoiceFilter( group = django_filters.MultipleChoiceFilter(
choices=DHGroupChoices choices=DHGroupChoices
) )
ike_policy_id = django_filters.ModelMultipleChoiceFilter(
field_name='ike_policies',
queryset=IKEPolicy.objects.all(),
label=_('IKE policy (ID)'),
)
ike_policy = django_filters.ModelMultipleChoiceFilter(
field_name='ike_policies__name',
queryset=IKEPolicy.objects.all(),
to_field_name='name',
label=_('IKE policy (name)'),
)
class Meta: class Meta:
model = IKEProposal model = IKEProposal

View File

@ -331,6 +331,16 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
IKEProposal.objects.bulk_create(ike_proposals) IKEProposal.objects.bulk_create(ike_proposals)
ike_policies = (
IKEPolicy(name='IKE Policy 1'),
IKEPolicy(name='IKE Policy 2'),
IKEPolicy(name='IKE Policy 3'),
)
IKEPolicy.objects.bulk_create(ike_policies)
ike_policies[0].proposals.add(ike_proposals[0])
ike_policies[1].proposals.add(ike_proposals[1])
ike_policies[2].proposals.add(ike_proposals[2])
def test_q(self): def test_q(self):
params = {'q': 'foobar1'} params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@ -369,6 +379,13 @@ class IKEProposalTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'sa_lifetime': [1000, 2000]} params = {'sa_lifetime': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_ike_policy(self):
ike_policies = IKEPolicy.objects.all()[:2]
params = {'ike_policy_id': [ike_policies[0].pk, ike_policies[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'ike_policy': [ike_policies[0].name, ike_policies[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests): class IKEPolicyTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IKEPolicy.objects.all() queryset = IKEPolicy.objects.all()

View File

@ -42,7 +42,7 @@ class WirelessLANImportForm(NetBoxModelImportForm):
status = CSVChoiceField( status = CSVChoiceField(
label=_('Status'), label=_('Status'),
choices=WirelessLANStatusChoices, choices=WirelessLANStatusChoices,
help_text='Operational status' help_text=_('Operational status')
) )
vlan = CSVModelChoiceField( vlan = CSVModelChoiceField(
label=_('VLAN'), label=_('VLAN'),

View File

@ -18,11 +18,11 @@ drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.4.1 drf-spectacular-sidecar==2024.4.1
feedparser==6.0.11 feedparser==6.0.11
graphene-django==3.0.0 graphene-django==3.0.0
gunicorn==21.2.0 gunicorn==22.0.0
Jinja2==3.1.3 Jinja2==3.1.3
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.17 mkdocs-material==9.5.18
mkdocstrings[python-legacy]==0.24.2 mkdocstrings[python-legacy]==0.24.3
netaddr==1.2.1 netaddr==1.2.1
Pillow==10.3.0 Pillow==10.3.0
psycopg[binary,pool]==3.1.18 psycopg[binary,pool]==3.1.18