Compare commits
38 Commits
c3466cf226
...
34b7c3a9a1
Author | SHA1 | Date |
---|---|---|
Smixi | 34b7c3a9a1 | |
Daniel Sheppard | 85db007ff5 | |
Daniel Sheppard | cad3e34d8f | |
Daniel Sheppard | 7b1b91b8ee | |
Daniel Sheppard | 6f36b8513c | |
Daniel Sheppard | 07e2cf0ad2 | |
Jeremy Stretch | d606cf1b3c | |
Jeremy Stretch | 0b0dab42eb | |
Jeremy Stretch | d115601da3 | |
Jeremy Stretch | a61e20849b | |
Arthur Hanson | 1eca1c3d17 | |
transifex-integration[bot] | 5d95d49268 | |
Jeremy Stretch | 6b8bfe9947 | |
Jeremy Stretch | e87877b6ea | |
Jeremy Stretch | ebe504c825 | |
Markku Leiniö | b6e38b2ebe | |
Arthur Hanson | 90d0104359 | |
Arthur Hanson | 88facbafbb | |
Jeremy Stretch | c9de3128ca | |
Arthur | 94c31622ac | |
Jeremy Stretch | 3d3c1c315b | |
Jeff Gehlbach | f4c8f5f5b6 | |
Jeremy Stretch | 19fe5ef25c | |
Arthur Hanson | 928014c766 | |
Sami Tahri | 832f7452f2 | |
Per von Zweigbergk | 8fadd6b744 | |
Per von Zweigbergk | c93413dc9c | |
Per von Zweigbergk | bf362f4679 | |
Per von Zweigbergk | da7f67c359 | |
Moritz Geist | 2c93dd03e1 | |
Moritz Geist | ced44832f7 | |
Moritz Geist | 6af3aad362 | |
Per von Zweigbergk | c728d3c2e8 | |
Per von Zweigbergk | 83e2c45e74 | |
Per von Zweigbergk | 27864ec865 | |
Per von Zweigbergk | d44f67aea5 | |
Per von Zweigbergk | 41e1f24cf7 | |
Per von Zweigbergk | d76ede17d3 |
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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')),
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 %}
|
||||||
|
|
|
@ -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
Binary file not shown.
|
@ -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."
|
||||||
|
|
Binary file not shown.
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue