Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch
2024-02-05 14:38:26 -05:00
106 changed files with 30533 additions and 2746 deletions

View File

@@ -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',
]

View File

@@ -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'),

View File

@@ -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"

View File

@@ -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().

View 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)

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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 = """

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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>"),
}

View File

@@ -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),
),
]

View File

@@ -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]

View File

@@ -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()

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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
)

View File

@@ -214,7 +214,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
required=False,
selector=True,
query_params={
'site_id': '$site',
'available_at_site': '$site',
},
label=_('VLAN'),
)

View File

@@ -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 = (

View File

@@ -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):

View File

@@ -36,3 +36,7 @@ DEFAULT_ACTION_PERMISSIONS = {
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'

View File

@@ -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 = (

View File

@@ -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)),
],

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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

View 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')},
),
]

View File

@@ -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')),
)

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -96,7 +96,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
}
)
description = forms.CharField(
label=_('Site'),
label=_('Description'),
max_length=200,
required=False
)

View File

@@ -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)),

View File

@@ -164,7 +164,7 @@ class IKEPolicyBulkEditForm(NetBoxModelBulkEditForm):
)),
)
nullable_fields = (
'preshared_key', 'description', 'comments',
'mode', 'preshared_key', 'description', 'comments',
)

View File

@@ -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(),

View File

@@ -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',

View File

@@ -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'),
),
]

View 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),
),
]

View File

@@ -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

View File

@@ -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
)

View File

@@ -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,
}