mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-19 03:42:25 -06:00
Merge branch 'develop' into feature
This commit is contained in:
@@ -36,7 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
|
||||
model = DataSource
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
|
||||
'parameters', 'ignore_rules', 'created', 'last_updated', 'file_count',
|
||||
'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Enforce unique space')
|
||||
label=_('Enabled')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
||||
@@ -9,9 +9,9 @@ class Command(_Command):
|
||||
"""
|
||||
This built-in management command enables the creation of new database schema migration files, which should
|
||||
never be required by and ordinary user. We prevent this command from executing unless the configuration
|
||||
indicates that the user is a developer (i.e. configuration.DEVELOPER == True).
|
||||
indicates that the user is a developer (i.e. configuration.DEVELOPER == True), or it was run with --check.
|
||||
"""
|
||||
if not settings.DEVELOPER:
|
||||
if not kwargs['check_changes'] and not settings.DEVELOPER:
|
||||
raise CommandError(
|
||||
"This command is available for development purposes only. It will\n"
|
||||
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import JobsMixin
|
||||
from netbox.registry import registry
|
||||
@@ -130,6 +131,28 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
# Censor any backend parameters marked as sensitive in the serialized data
|
||||
pre_change_params = {}
|
||||
post_change_params = {}
|
||||
if objectchange.prechange_data:
|
||||
pre_change_params = objectchange.prechange_data.get('parameters') or {} # parameters may be None
|
||||
if objectchange.postchange_data:
|
||||
post_change_params = objectchange.postchange_data.get('parameters') or {}
|
||||
for param in self.backend_class.sensitive_parameters:
|
||||
if post_change_params.get(param):
|
||||
if post_change_params[param] != pre_change_params.get(param):
|
||||
# Set the "changed" token if the parameter's value has been modified
|
||||
post_change_params[param] = CENSOR_TOKEN_CHANGED
|
||||
else:
|
||||
post_change_params[param] = CENSOR_TOKEN
|
||||
if pre_change_params.get(param):
|
||||
pre_change_params[param] = CENSOR_TOKEN
|
||||
|
||||
return objectchange
|
||||
|
||||
def enqueue_sync_job(self, request):
|
||||
"""
|
||||
Enqueue a background job to synchronize the DataSource by calling sync().
|
||||
|
||||
122
netbox/core/tests/test_models.py
Normal file
122
netbox/core/tests/test_models.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import DataSource
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
|
||||
|
||||
class DataSourceChangeLoggingTestCase(TestCase):
|
||||
|
||||
def test_password_added_on_create(self):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='git',
|
||||
source_url='http://localhost/',
|
||||
parameters={
|
||||
'username': 'jeff',
|
||||
'password': 'foobar123',
|
||||
}
|
||||
)
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertIsNone(objectchange.prechange_data)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
|
||||
|
||||
def test_password_added_on_update(self):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='git',
|
||||
source_url='http://localhost/'
|
||||
)
|
||||
datasource.snapshot()
|
||||
|
||||
# Add a blank password
|
||||
datasource.parameters = {
|
||||
'username': 'jeff',
|
||||
'password': '',
|
||||
}
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertIsNone(objectchange.prechange_data['parameters'])
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
|
||||
|
||||
# Add a password
|
||||
datasource.parameters = {
|
||||
'username': 'jeff',
|
||||
'password': 'foobar123',
|
||||
}
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
|
||||
|
||||
def test_password_changed(self):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='git',
|
||||
source_url='http://localhost/',
|
||||
parameters={
|
||||
'username': 'jeff',
|
||||
'password': 'password1',
|
||||
}
|
||||
)
|
||||
datasource.snapshot()
|
||||
|
||||
# Change the password
|
||||
datasource.parameters['password'] = 'password2'
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
|
||||
|
||||
def test_password_removed_on_update(self):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='git',
|
||||
source_url='http://localhost/',
|
||||
parameters={
|
||||
'username': 'jeff',
|
||||
'password': 'foobar123',
|
||||
}
|
||||
)
|
||||
datasource.snapshot()
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
|
||||
# Remove the password
|
||||
datasource.parameters['password'] = ''
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
|
||||
|
||||
def test_password_not_modified(self):
|
||||
datasource = DataSource.objects.create(
|
||||
name='Data Source 1',
|
||||
type='git',
|
||||
source_url='http://localhost/',
|
||||
parameters={
|
||||
'username': 'username1',
|
||||
'password': 'foobar123',
|
||||
}
|
||||
)
|
||||
datasource.snapshot()
|
||||
|
||||
# Remove the password
|
||||
datasource.parameters['username'] = 'username2'
|
||||
|
||||
objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['username'], 'username1')
|
||||
self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
|
||||
self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
|
||||
@@ -727,7 +727,7 @@ class PowerOutletImportForm(NetBoxModelImportForm):
|
||||
help_text=_('Local power port which feeds this outlet')
|
||||
)
|
||||
feed_leg = CSVChoiceField(
|
||||
label=_('Feed lag'),
|
||||
label=_('Feed leg'),
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
required=False,
|
||||
help_text=_('Electrical phase (for three-phase circuits)')
|
||||
@@ -1359,6 +1359,10 @@ class VirtualDeviceContextImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text='Assigned tenant'
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
label=_('Status'),
|
||||
choices=VirtualDeviceContextStatusChoices,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import graphene
|
||||
from circuits.graphql.types import CircuitTerminationType
|
||||
from circuits.models import CircuitTermination
|
||||
from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
|
||||
from circuits.models import CircuitTermination, ProviderNetwork
|
||||
from dcim.graphql.types import (
|
||||
ConsolePortTemplateType,
|
||||
ConsolePortType,
|
||||
@@ -167,3 +167,42 @@ class InventoryItemComponentType(graphene.Union):
|
||||
return PowerPortType
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
|
||||
class ConnectedEndpointType(graphene.Union):
|
||||
class Meta:
|
||||
types = (
|
||||
CircuitTerminationType,
|
||||
ConsolePortType,
|
||||
ConsoleServerPortType,
|
||||
FrontPortType,
|
||||
InterfaceType,
|
||||
PowerFeedType,
|
||||
PowerOutletType,
|
||||
PowerPortType,
|
||||
ProviderNetworkType,
|
||||
RearPortType,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) is ConsolePortType:
|
||||
return ConsolePortType
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) is PowerFeed:
|
||||
return PowerFeedType
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) is ProviderNetwork:
|
||||
return ProviderNetworkType
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
@@ -13,7 +13,7 @@ class CabledObjectMixin:
|
||||
|
||||
|
||||
class PathEndpointMixin:
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.ConnectedEndpointType')
|
||||
|
||||
def resolve_connected_endpoints(self, info):
|
||||
# Handle empty values
|
||||
|
||||
@@ -1115,7 +1115,7 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
installed_device = models.OneToOneField(
|
||||
to='dcim.Device',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name=_('parent_bay'),
|
||||
related_name='parent_bay',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
@@ -35,13 +35,17 @@ DEVICEBAY_STATUS = """
|
||||
"""
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
{% for ip in value.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="badge text-bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if value.count > 3 %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a>
|
||||
{% else %}
|
||||
{% for ip in value.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="badge text-bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
INTERFACE_FHRPGROUPS = """
|
||||
|
||||
@@ -58,7 +58,11 @@ class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
|
||||
|
||||
|
||||
class DeviceTypeComponentsView(DeviceComponentsView):
|
||||
class DeviceTypeComponentsView(generic.ObjectChildrenView):
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
queryset = DeviceType.objects.all()
|
||||
template_name = 'dcim/devicetype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import ListField
|
||||
|
||||
from core.api.nested_serializers import NestedDataSourceSerializer, NestedDataFileSerializer, NestedJobSerializer
|
||||
from core.api.serializers import JobSerializer
|
||||
@@ -126,11 +127,15 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||
object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
data_type = serializers.SerializerMethodField()
|
||||
choice_set = NestedCustomFieldChoiceSetSerializer(required=False)
|
||||
choice_set = NestedCustomFieldChoiceSetSerializer(
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
ui_visible = ChoiceField(choices=CustomFieldUIVisibleChoices, required=False)
|
||||
ui_editable = ChoiceField(choices=CustomFieldUIEditableChoices, required=False)
|
||||
|
||||
@@ -171,6 +176,12 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
|
||||
choices=CustomFieldChoiceSetBaseChoices,
|
||||
required=False
|
||||
)
|
||||
extra_choices = serializers.ListField(
|
||||
child=serializers.ListField(
|
||||
min_length=2,
|
||||
max_length=2
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
|
||||
@@ -53,13 +53,13 @@ def get_dashboard(user):
|
||||
return dashboard
|
||||
|
||||
|
||||
def get_default_dashboard():
|
||||
def get_default_dashboard(config=None):
|
||||
from extras.models import Dashboard
|
||||
|
||||
dashboard = Dashboard()
|
||||
default_config = settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
|
||||
config = config or settings.DEFAULT_DASHBOARD or DEFAULT_DASHBOARD
|
||||
|
||||
for widget in default_config:
|
||||
for widget in config:
|
||||
id = str(uuid.uuid4())
|
||||
dashboard.layout.append({
|
||||
'id': id,
|
||||
|
||||
@@ -71,17 +71,17 @@ def enqueue_object(queue, instance, user, request_id, action):
|
||||
})
|
||||
|
||||
|
||||
def process_event_rules(event_rules, model_name, event, data, username, snapshots=None, request_id=None):
|
||||
try:
|
||||
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
||||
if username:
|
||||
user = get_user_model().objects.get(username=username)
|
||||
except ObjectDoesNotExist:
|
||||
else:
|
||||
user = None
|
||||
|
||||
for event_rule in event_rules:
|
||||
|
||||
# Evaluate event rule conditions (if any)
|
||||
if not event_rule.eval_conditions(data):
|
||||
return
|
||||
continue
|
||||
|
||||
# Webhooks
|
||||
if event_rule.action_type == EventRuleActionChoices.WEBHOOK:
|
||||
|
||||
@@ -142,10 +142,12 @@ class CustomLinkForm(forms.ModelForm):
|
||||
}
|
||||
help_texts = {
|
||||
'link_text': _(
|
||||
"Jinja2 template code for the link text. Reference the object as <code>{{ object }}</code>. Links "
|
||||
"Jinja2 template code for the link text. Reference the object as {example}. Links "
|
||||
"which render as empty text will not be displayed."
|
||||
),
|
||||
'link_url': _("Jinja2 template code for the link URL. Reference the object as <code>{{ object }}</code>."),
|
||||
).format(example="<code>{{ object }}</code>"),
|
||||
'link_url': _(
|
||||
"Jinja2 template code for the link URL. Reference the object as {example}."
|
||||
).format(example="<code>{{ object }}</code>"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.2.9 on 2024-01-19 19:46
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('extras', '0105_customfield_min_max_values'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,16 @@ __all__ = (
|
||||
|
||||
class PythonModuleMixin:
|
||||
|
||||
def get_jobs(self, name):
|
||||
"""
|
||||
Returns a list of Jobs associated with this specific script or report module
|
||||
:param name: The class name of the script or report
|
||||
:return: List of Jobs associated with this
|
||||
"""
|
||||
return self.jobs.filter(
|
||||
name=name
|
||||
)
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return os.path.splitext(self.file_path)[0]
|
||||
|
||||
@@ -771,7 +771,7 @@ class Bookmark(models.Model):
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
@@ -120,34 +120,29 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
if self.model._meta.model_name == 'device':
|
||||
base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
|
||||
base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||
region_field = 'site__region'
|
||||
sitegroup_field = 'site__group'
|
||||
|
||||
elif self.model._meta.model_name == 'virtualmachine':
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
|
||||
base_query.add(Q(device_types=None), Q.AND)
|
||||
region_field = 'cluster__site__region'
|
||||
sitegroup_field = 'cluster__site__group'
|
||||
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||
|
||||
base_query.add(
|
||||
(Q(
|
||||
regions__tree_id=OuterRef(f'{region_field}__tree_id'),
|
||||
regions__level__lte=OuterRef(f'{region_field}__level'),
|
||||
regions__lft__lte=OuterRef(f'{region_field}__lft'),
|
||||
regions__rght__gte=OuterRef(f'{region_field}__rght'),
|
||||
regions__tree_id=OuterRef('site__region__tree_id'),
|
||||
regions__level__lte=OuterRef('site__region__level'),
|
||||
regions__lft__lte=OuterRef('site__region__lft'),
|
||||
regions__rght__gte=OuterRef('site__region__rght'),
|
||||
) | Q(regions=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
base_query.add(
|
||||
(Q(
|
||||
site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'),
|
||||
site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'),
|
||||
site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'),
|
||||
site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'),
|
||||
site_groups__tree_id=OuterRef('site__group__tree_id'),
|
||||
site_groups__level__lte=OuterRef('site__group__level'),
|
||||
site_groups__lft__lte=OuterRef('site__group__lft'),
|
||||
site_groups__rght__gte=OuterRef('site__group__rght'),
|
||||
) | Q(site_groups=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
@@ -68,21 +68,23 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
# Create/update an ObejctChange record for this change
|
||||
objectchange = instance.to_objectchange(action)
|
||||
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
|
||||
# for this object by this request and update it
|
||||
if m2m_changed and (
|
||||
prev_change := ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(instance),
|
||||
changed_object_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
if objectchange and objectchange.has_changes:
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
).first()
|
||||
):
|
||||
prev_change.postchange_data = objectchange.postchange_data
|
||||
prev_change.save()
|
||||
elif objectchange and objectchange.has_changes:
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
queue = events_queue.get()
|
||||
@@ -251,7 +253,8 @@ def process_job_start_event_rules(sender, **kwargs):
|
||||
Process event rules for jobs starting.
|
||||
"""
|
||||
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, sender.user.username)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
|
||||
|
||||
|
||||
@receiver(job_end)
|
||||
@@ -260,4 +263,5 @@ def process_job_end_event_rules(sender, **kwargs):
|
||||
Process event rules for jobs terminating.
|
||||
"""
|
||||
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, sender.user.username)
|
||||
username = sender.user.username if sender.user else None
|
||||
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
|
||||
|
||||
@@ -14,7 +14,6 @@ from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@@ -251,6 +250,23 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||
|
||||
def test_invalid_choice_items(self):
|
||||
"""
|
||||
Attempting to define each choice as a single-item list should return a 400 error.
|
||||
"""
|
||||
self.add_permissions('extras.add_customfieldchoiceset')
|
||||
data = {
|
||||
"name": "test",
|
||||
"extra_choices": [
|
||||
["choice1"],
|
||||
["choice2"],
|
||||
["choice3"],
|
||||
]
|
||||
}
|
||||
|
||||
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
||||
model = CustomLink
|
||||
|
||||
@@ -270,7 +270,12 @@ class ConfigContextTest(TestCase):
|
||||
tag = Tag.objects.first()
|
||||
cluster_type = ClusterType.objects.create(name="Cluster Type")
|
||||
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
||||
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
||||
cluster = Cluster.objects.create(
|
||||
name="Cluster",
|
||||
group=cluster_group,
|
||||
type=cluster_type,
|
||||
site=site,
|
||||
)
|
||||
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
@@ -354,6 +359,41 @@ class ConfigContextTest(TestCase):
|
||||
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
|
||||
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_virtualmachine_site_context(self):
|
||||
"""
|
||||
Check that config context associated with a site applies to a VM whether the VM is assigned
|
||||
directly to that site or via its cluster.
|
||||
"""
|
||||
site = Site.objects.first()
|
||||
cluster_type = ClusterType.objects.create(name="Cluster Type")
|
||||
cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site)
|
||||
vm_role = DeviceRole.objects.first()
|
||||
|
||||
# Create a ConfigContext associated with the site
|
||||
context = ConfigContext.objects.create(
|
||||
name="context1",
|
||||
weight=100,
|
||||
data={"foo": True}
|
||||
)
|
||||
context.sites.add(site)
|
||||
|
||||
# Create one VM assigned directly to the site, and one assigned via the cluster
|
||||
vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
|
||||
vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
|
||||
|
||||
# Check that their individually-rendered config contexts are identical
|
||||
self.assertEqual(
|
||||
vm1.get_config_context(),
|
||||
vm2.get_config_context()
|
||||
)
|
||||
|
||||
# Check that their annotated config contexts are identical
|
||||
vms = VirtualMachine.objects.filter(pk__in=(vm1.pk, vm2.pk)).annotate_config_context_data()
|
||||
self.assertEqual(
|
||||
vms[0].get_config_context(),
|
||||
vms[1].get_config_context()
|
||||
)
|
||||
|
||||
def test_multiple_tags_return_distinct_objects(self):
|
||||
"""
|
||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||
|
||||
@@ -1056,16 +1056,14 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
report.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name,
|
||||
report.result = jobs.filter(
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
|
||||
@@ -1077,6 +1075,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
||||
|
||||
if form.is_valid():
|
||||
@@ -1085,6 +1084,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
if not get_workers_for_queue('default'):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'report': report,
|
||||
})
|
||||
|
||||
@@ -1102,6 +1102,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
return redirect('extras:report_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'form': form,
|
||||
@@ -1116,8 +1117,10 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
return render(request, 'extras/report/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'tab': 'source',
|
||||
@@ -1132,13 +1135,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.class_name
|
||||
)
|
||||
jobs = module.get_jobs(report.class_name)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
@@ -1148,6 +1145,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/report/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'report': report,
|
||||
'table': jobs_table,
|
||||
@@ -1231,19 +1229,11 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
# Look for a pending Job (use the latest one by creation timestamp)
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
script.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=script.name,
|
||||
).exclude(
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
@@ -1255,6 +1245,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
|
||||
# Allow execution only if RQ worker process is running
|
||||
@@ -1278,6 +1269,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
return redirect('extras:script_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
@@ -1292,8 +1284,10 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'tab': 'source',
|
||||
@@ -1308,13 +1302,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
def get(self, request, module, name):
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=script.class_name
|
||||
)
|
||||
jobs = module.get_jobs(script.class_name)
|
||||
|
||||
jobs_table = JobTable(
|
||||
data=jobs,
|
||||
@@ -1324,6 +1312,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
jobs_table.configure(request)
|
||||
|
||||
return render(request, 'extras/script/jobs.html', {
|
||||
'job_count': jobs.count(),
|
||||
'module': module,
|
||||
'script': script,
|
||||
'table': jobs_table,
|
||||
|
||||
@@ -254,7 +254,7 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Treat as 100% utilized')
|
||||
label=_('Treat as fully utilized')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
@@ -298,7 +298,7 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect(),
|
||||
label=_('Treat as 100% utilized')
|
||||
label=_('Treat as fully utilized')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
||||
@@ -240,7 +240,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Marked as 100% utilized'),
|
||||
label=_('Treat as fully utilized'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
@@ -279,7 +279,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
)
|
||||
mark_utilized = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Marked as 100% utilized'),
|
||||
label=_('Treat as fully utilized'),
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
|
||||
@@ -214,7 +214,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'available_at_site': '$site',
|
||||
},
|
||||
label=_('VLAN'),
|
||||
)
|
||||
|
||||
@@ -268,7 +268,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
mark_utilized = models.BooleanField(
|
||||
verbose_name=_('mark utilized'),
|
||||
default=False,
|
||||
help_text=_("Treat as 100% utilized")
|
||||
help_text=_("Treat as fully utilized")
|
||||
)
|
||||
|
||||
# Cached depth & child counts
|
||||
@@ -427,10 +427,10 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||
child_ranges = netaddr.IPSet()
|
||||
child_ranges = []
|
||||
for iprange in self.get_child_ranges():
|
||||
child_ranges.add(iprange.range)
|
||||
available_ips = prefix - child_ips - child_ranges
|
||||
child_ranges.append(iprange.range)
|
||||
available_ips = prefix - child_ips - netaddr.IPSet(child_ranges)
|
||||
|
||||
# IPv6 /127's, pool, or IPv4 /31-/32 sets are fully usable
|
||||
if (self.family == 6 and self.prefix.prefixlen >= 127) or self.is_pool or (self.family == 4 and self.prefix.prefixlen >= 31):
|
||||
@@ -535,7 +535,7 @@ class IPRange(PrimaryModel):
|
||||
mark_utilized = models.BooleanField(
|
||||
verbose_name=_('mark utilized'),
|
||||
default=False,
|
||||
help_text=_("Treat as 100% utilized")
|
||||
help_text=_("Treat as fully utilized")
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
|
||||
@@ -604,7 +604,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
if not get_table_ordering(request, self.table):
|
||||
if not request.GET.get('q') and not get_table_ordering(request, self.table):
|
||||
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
|
||||
return queryset
|
||||
|
||||
@@ -1068,6 +1068,12 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
|
||||
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
|
||||
return instance
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
return {
|
||||
'interface_type': request.GET.get('interface_type'),
|
||||
'interface_id': request.GET.get('interface_id'),
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(FHRPGroupAssignment, 'delete')
|
||||
class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
@@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
|
||||
# General-purpose tokens
|
||||
CENSOR_TOKEN = '********'
|
||||
CENSOR_TOKEN_CHANGED = '***CHANGED***'
|
||||
|
||||
@@ -29,7 +29,7 @@ from netbox.plugins import PluginConfig
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.7-beta1'
|
||||
VERSION = '3.7.3-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -124,7 +124,6 @@ EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', (
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
GIT_PATH = getattr(configuration, 'GIT_PATH', 'git')
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
@@ -736,8 +735,10 @@ LANGUAGES = (
|
||||
('en', _('English')),
|
||||
('es', _('Spanish')),
|
||||
('fr', _('French')),
|
||||
('ja', _('Japanese')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('tr', _('Turkish')),
|
||||
)
|
||||
|
||||
LOCALE_PATHS = (
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||
migrations.CreateModel(
|
||||
name='DummyModel',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=20)),
|
||||
('number', models.IntegerField(default=100)),
|
||||
],
|
||||
|
||||
@@ -2,14 +2,17 @@ import re
|
||||
from collections import namedtuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
from packaging import version
|
||||
|
||||
from extras.dashboard.utils import get_dashboard
|
||||
from extras.constants import DEFAULT_DASHBOARD
|
||||
from extras.dashboard.utils import get_dashboard, get_default_dashboard
|
||||
from netbox.forms import SearchForm
|
||||
from netbox.search import LookupTypes
|
||||
from netbox.search.backends import search_backend
|
||||
@@ -32,7 +35,13 @@ class HomeView(View):
|
||||
return redirect('login')
|
||||
|
||||
# Construct the user's custom dashboard layout
|
||||
dashboard = get_dashboard(request.user).get_layout()
|
||||
try:
|
||||
dashboard = get_dashboard(request.user).get_layout()
|
||||
except Exception:
|
||||
messages.error(request, _(
|
||||
"There was an error loading the dashboard configuration. A default dashboard is in use."
|
||||
))
|
||||
dashboard = get_default_dashboard(config=DEFAULT_DASHBOARD).get_layout()
|
||||
|
||||
# Check whether a new release is available. (Only for staff/superusers.)
|
||||
new_release = None
|
||||
|
||||
@@ -1,44 +1,38 @@
|
||||
{% extends 'dcim/devicetype/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% extends 'generic/object_children.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% load perms %}
|
||||
|
||||
{% block content %}
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
<div class="card-footer d-print-none">
|
||||
{% if table.rows %}
|
||||
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning">
|
||||
<span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
|
||||
</button>
|
||||
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="float-end">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% block bulk_edit_controls %}
|
||||
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
|
||||
{% if 'bulk_edit' in actions and bulk_edit_view %}
|
||||
<button type="submit" name="_edit"
|
||||
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
|
||||
class="btn btn-warning">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
|
||||
{% if 'bulk_rename' in actions and bulk_rename_view %}
|
||||
<button type="submit" name="_rename"
|
||||
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}"
|
||||
class="btn btn-outline-warning">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock bulk_edit_controls %}
|
||||
|
||||
{% block bulk_extra_controls %}
|
||||
{{ block.super }}
|
||||
{% if request.user|can_add:child_model %}
|
||||
<div class="bulk-button-group">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">{{ title }}</h5>
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
{% endif %}
|
||||
{% endblock bulk_extra_controls %}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:report_jobs' module=report.module name=report.class_name %}">
|
||||
{% trans "Jobs" %} {% badge module.jobs.count %}
|
||||
{% trans "Jobs" %} {% badge job_count %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link{% if tab == 'jobs' %} active{% endif %}" href="{% url 'extras:script_jobs' module=script.module name=script.class_name %}">
|
||||
{% trans "Jobs" %} {% badge module.jobs.count %}
|
||||
{% trans "Jobs" %} {% badge job_count %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
{% with first_available_ip=object.get_first_available_ip %}
|
||||
{% if first_available_ip %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}">{{ first_available_ip }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}{% if object.vrf %}&vrf={{ object.vrf_id }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}">{{ first_available_ip }}</a>
|
||||
{% else %}
|
||||
{{ first_available_ip }}
|
||||
{% endif %}
|
||||
|
||||
@@ -83,6 +83,6 @@ class ContactViewSet(NetBoxModelViewSet):
|
||||
|
||||
|
||||
class ContactAssignmentViewSet(NetBoxModelViewSet):
|
||||
queryset = ContactAssignment.objects.prefetch_related('object', 'contact', 'role')
|
||||
queryset = ContactAssignment.objects.prefetch_related('content_type', 'object', 'contact', 'role', 'tags')
|
||||
serializer_class = serializers.ContactAssignmentSerializer
|
||||
filterset_class = filtersets.ContactAssignmentFilterSet
|
||||
|
||||
17
netbox/tenancy/migrations/0014_contactassignment_ordering.py
Normal file
17
netbox/tenancy/migrations/0014_contactassignment_ordering.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.8 on 2024-01-17 15:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0013_gfk_indexes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='contactassignment',
|
||||
options={'ordering': ('contact', 'priority', 'role', 'pk')},
|
||||
),
|
||||
]
|
||||
@@ -140,7 +140,7 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan
|
||||
clone_fields = ('content_type', 'object_id', 'role', 'priority')
|
||||
|
||||
class Meta:
|
||||
ordering = ('priority', 'contact')
|
||||
ordering = ('contact', 'priority', 'role', 'pk')
|
||||
indexes = (
|
||||
models.Index(fields=('content_type', 'object_id')),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ class ObjectContactsView(generic.ObjectChildrenView):
|
||||
return ContactAssignment.objects.restrict(request.user, 'view').filter(
|
||||
content_type=ContentType.objects.get_for_model(parent),
|
||||
object_id=parent.pk
|
||||
)
|
||||
).order_by('priority', 'contact', 'role')
|
||||
|
||||
def get_table(self, *args, **kwargs):
|
||||
table = super().get_table(*args, **kwargs)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
netbox/translations/ja/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/ja/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
13359
netbox/translations/ja/LC_MESSAGES/django.po
Normal file
13359
netbox/translations/ja/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
netbox/translations/tr/LC_MESSAGES/django.mo
Normal file
BIN
netbox/translations/tr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
13565
netbox/translations/tr/LC_MESSAGES/django.po
Normal file
13565
netbox/translations/tr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,13 @@ def get_serializer_for_model(model, prefix=''):
|
||||
# Serializers for Django's auth models are in the users app
|
||||
if app_name == 'auth':
|
||||
app_name = 'users'
|
||||
# Account for changes using Proxy model
|
||||
if app_name == 'users':
|
||||
if model_name == 'NetBoxUser':
|
||||
model_name = 'User'
|
||||
elif model_name == 'NetBoxGroup':
|
||||
model_name = 'Group'
|
||||
|
||||
serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer'
|
||||
try:
|
||||
return dynamic_import(serializer_name)
|
||||
|
||||
@@ -105,7 +105,12 @@ class JSONField(_JSONField):
|
||||
return value
|
||||
if value in ('', None):
|
||||
return ''
|
||||
return json.dumps(value, sort_keys=True, indent=4)
|
||||
if type(value) is str:
|
||||
try:
|
||||
value = json.loads(value, cls=self.decoder)
|
||||
except json.decoder.JSONDecodeError:
|
||||
return value
|
||||
return json.dumps(value, sort_keys=True, indent=4, ensure_ascii=False, cls=self.encoder)
|
||||
|
||||
|
||||
class MACAddressField(forms.Field):
|
||||
|
||||
@@ -24,8 +24,9 @@ def can_view(user, instance):
|
||||
|
||||
|
||||
@register.filter()
|
||||
def can_add(user, instance):
|
||||
return _check_permission(user, instance, 'add')
|
||||
def can_add(user, model):
|
||||
permission = get_permission_for_model(model, 'add')
|
||||
return user.has_perm(perm=permission)
|
||||
|
||||
|
||||
@register.filter()
|
||||
|
||||
@@ -53,6 +53,8 @@ def get_viewname(model, action=None, rest_api=False):
|
||||
# Alter the app_label for group and user model_name to point to users app
|
||||
if app_label == 'auth' and model_name in ['group', 'user']:
|
||||
app_label = 'users'
|
||||
if app_label == 'users' and model._meta.proxy and model_name in ['netboxuser', 'netboxgroup']:
|
||||
model_name = model._meta.proxy_for_model._meta.model_name
|
||||
|
||||
viewname = f'{app_label}-api:{model_name}'
|
||||
# Append the action, if any
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
||||
@@ -9,5 +11,10 @@ class VirtualizationConfig(AppConfig):
|
||||
from .models import VirtualMachine
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(VirtualMachine, 'cluster', {
|
||||
'site': 'site',
|
||||
})
|
||||
|
||||
# Register counters
|
||||
connect_counters(VirtualMachine)
|
||||
|
||||
@@ -96,7 +96,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
|
||||
}
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Site'),
|
||||
label=_('Description'),
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -38,11 +38,11 @@ class TunnelEncapsulationChoices(ChoiceSet):
|
||||
class TunnelTerminationTypeChoices(ChoiceSet):
|
||||
# For TunnelCreateForm
|
||||
TYPE_DEVICE = 'dcim.device'
|
||||
TYPE_VIRUTALMACHINE = 'virtualization.virtualmachine'
|
||||
TYPE_VIRTUALMACHINE = 'virtualization.virtualmachine'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_DEVICE, _('Device')),
|
||||
(TYPE_VIRUTALMACHINE, _('Virtual Machine')),
|
||||
(TYPE_VIRTUALMACHINE, _('Virtual Machine')),
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ class DHGroupChoices(ChoiceSet):
|
||||
(GROUP_2, _('Group {n}').format(n=2)),
|
||||
(GROUP_5, _('Group {n}').format(n=5)),
|
||||
(GROUP_14, _('Group {n}').format(n=14)),
|
||||
(GROUP_15, _('Group {n}').format(n=15)),
|
||||
(GROUP_16, _('Group {n}').format(n=16)),
|
||||
(GROUP_17, _('Group {n}').format(n=17)),
|
||||
(GROUP_18, _('Group {n}').format(n=18)),
|
||||
|
||||
@@ -164,7 +164,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
|
||||
)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'preshared_key', 'description', 'comments',
|
||||
'mode', 'preshared_key', 'description', 'comments',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -151,7 +151,8 @@ class IKEProposalImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
authentication_algorithm = CSVChoiceField(
|
||||
label=_('Authentication algorithm'),
|
||||
choices=AuthenticationAlgorithmChoices
|
||||
choices=AuthenticationAlgorithmChoices,
|
||||
required=False
|
||||
)
|
||||
group = CSVChoiceField(
|
||||
label=_('Group'),
|
||||
@@ -173,7 +174,8 @@ class IKEPolicyImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
mode = CSVChoiceField(
|
||||
label=_('Mode'),
|
||||
choices=IKEModeChoices
|
||||
choices=IKEModeChoices,
|
||||
required=False
|
||||
)
|
||||
proposals = CSVModelMultipleChoiceField(
|
||||
queryset=IKEProposal.objects.all(),
|
||||
@@ -191,11 +193,13 @@ class IKEPolicyImportForm(NetBoxModelImportForm):
|
||||
class IPSecProposalImportForm(NetBoxModelImportForm):
|
||||
encryption_algorithm = CSVChoiceField(
|
||||
label=_('Encryption algorithm'),
|
||||
choices=EncryptionAlgorithmChoices
|
||||
choices=EncryptionAlgorithmChoices,
|
||||
required=False
|
||||
)
|
||||
authentication_algorithm = CSVChoiceField(
|
||||
label=_('Authentication algorithm'),
|
||||
choices=AuthenticationAlgorithmChoices
|
||||
choices=AuthenticationAlgorithmChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -209,7 +213,8 @@ class IPSecProposalImportForm(NetBoxModelImportForm):
|
||||
class IPSecPolicyImportForm(NetBoxModelImportForm):
|
||||
pfs_group = CSVChoiceField(
|
||||
label=_('Diffie-Hellman group for Perfect Forward Secrecy'),
|
||||
choices=DHGroupChoices
|
||||
choices=DHGroupChoices,
|
||||
required=False
|
||||
)
|
||||
proposals = CSVModelMultipleChoiceField(
|
||||
queryset=IPSecProposal.objects.all(),
|
||||
|
||||
@@ -7,7 +7,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.utils import add_blank_choice, get_field_value
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from vpn.choices import *
|
||||
@@ -141,7 +141,7 @@ class TunnelCreateForm(TunnelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Tunnel'), ('name', 'status', 'encapsulation', 'description', 'tunnel_id', 'tags')),
|
||||
(_('Tunnel'), ('name', 'status', 'group', 'encapsulation', 'description', 'tunnel_id', 'tags')),
|
||||
(_('Security'), ('ipsec_profile',)),
|
||||
(_('Tenancy'), ('tenant_group', 'tenant')),
|
||||
(_('First Termination'), (
|
||||
@@ -157,7 +157,7 @@ class TunnelCreateForm(TunnelForm):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
if initial and initial.get('termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
|
||||
if get_field_value(self, 'termination1_type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
|
||||
self.fields['termination1_parent'].label = _('Virtual Machine')
|
||||
self.fields['termination1_parent'].queryset = VirtualMachine.objects.all()
|
||||
self.fields['termination1_termination'].queryset = VMInterface.objects.all()
|
||||
@@ -168,7 +168,7 @@ class TunnelCreateForm(TunnelForm):
|
||||
'virtual_machine_id': '$termination1_parent',
|
||||
})
|
||||
|
||||
if initial and initial.get('termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
|
||||
if get_field_value(self, 'termination2_type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
|
||||
self.fields['termination2_parent'].label = _('Virtual Machine')
|
||||
self.fields['termination2_parent'].queryset = VirtualMachine.objects.all()
|
||||
self.fields['termination2_termination'].queryset = VMInterface.objects.all()
|
||||
@@ -265,9 +265,15 @@ class TunnelTerminationForm(NetBoxModelForm):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
if initial and initial.get('type') == TunnelTerminationTypeChoices.TYPE_VIRUTALMACHINE:
|
||||
if (get_field_value(self, 'type') is None and
|
||||
self.instance.pk and isinstance(self.instance.termination.parent_object, VirtualMachine)):
|
||||
self.fields['type'].initial = TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE
|
||||
|
||||
# If initial or self.data is set and the type is a VIRTUALMACHINE type, swap the field querysets.
|
||||
if get_field_value(self, 'type') == TunnelTerminationTypeChoices.TYPE_VIRTUALMACHINE:
|
||||
self.fields['parent'].label = _('Virtual Machine')
|
||||
self.fields['parent'].queryset = VirtualMachine.objects.all()
|
||||
self.fields['parent'].widget.attrs['selector'] = 'virtualization.virtualmachine'
|
||||
self.fields['termination'].queryset = VMInterface.objects.all()
|
||||
self.fields['termination'].widget.add_query_params({
|
||||
'virtual_machine_id': '$parent',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.8 on 2024-01-05 19:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0069_gfk_indexes'),
|
||||
('vpn', '0002_move_l2vpn'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tunneltermination',
|
||||
name='outside_ip',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tunnel_terminations', to='ipam.ipaddress'),
|
||||
),
|
||||
]
|
||||
18
netbox/vpn/migrations/0004_alter_ikepolicy_mode.py
Normal file
18
netbox/vpn/migrations/0004_alter_ikepolicy_mode.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.9 on 2024-01-20 09:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('vpn', '0003_ipaddress_multiple_tunnel_terminations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ikepolicy',
|
||||
name='mode',
|
||||
field=models.CharField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -79,7 +79,8 @@ class IKEPolicy(PrimaryModel):
|
||||
)
|
||||
mode = models.CharField(
|
||||
verbose_name=_('mode'),
|
||||
choices=IKEModeChoices
|
||||
choices=IKEModeChoices,
|
||||
blank=True
|
||||
)
|
||||
proposals = models.ManyToManyField(
|
||||
to='vpn.IKEProposal',
|
||||
@@ -109,6 +110,17 @@ class IKEPolicy(PrimaryModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('vpn:ikepolicy', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Mode is required
|
||||
if self.version == IKEVersionChoices.VERSION_1 and not self.mode:
|
||||
raise ValidationError(_("Mode is required for selected IKE version"))
|
||||
|
||||
# Mode cannot be used
|
||||
if self.version == IKEVersionChoices.VERSION_2 and self.mode:
|
||||
raise ValidationError(_("Mode cannot be used for selected IKE version"))
|
||||
|
||||
|
||||
#
|
||||
# IPSec
|
||||
|
||||
@@ -129,10 +129,10 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo
|
||||
ct_field='termination_type',
|
||||
fk_field='termination_id'
|
||||
)
|
||||
outside_ip = models.OneToOneField(
|
||||
outside_ip = models.ForeignKey(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='tunnel_termination',
|
||||
related_name='tunnel_terminations',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
@@ -305,7 +305,6 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
cls.form_data = {
|
||||
'name': 'IKE Policy X',
|
||||
'version': IKEVersionChoices.VERSION_2,
|
||||
'mode': IKEModeChoices.AGGRESSIVE,
|
||||
'proposals': [p.pk for p in ike_proposals],
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@@ -313,9 +312,9 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
ike_proposal_names = ','.join([p.name for p in ike_proposals])
|
||||
cls.csv_data = (
|
||||
"name,version,mode,proposals",
|
||||
f"IKE Proposal 4,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 5,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 6,2,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 4,1,main,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 5,1,aggressive,\"{ike_proposal_names}\"",
|
||||
f"IKE Proposal 6,2,,\"{ike_proposal_names}\"",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -327,7 +326,7 @@ class IKEPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
'version': IKEVersionChoices.VERSION_2,
|
||||
'version': IKEVersionChoices.VERSION_1,
|
||||
'mode': IKEModeChoices.AGGRESSIVE,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user