nautobot 2.4.21__py3-none-any.whl → 2.4.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of nautobot might be problematic. Click here for more details.
- nautobot/apps/choices.py +4 -0
- nautobot/apps/utils.py +8 -0
- nautobot/circuits/views.py +6 -2
- nautobot/core/cli/migrate_deprecated_templates.py +28 -9
- nautobot/core/filters.py +4 -0
- nautobot/core/forms/__init__.py +2 -0
- nautobot/core/forms/widgets.py +21 -2
- nautobot/core/jobs/bulk_actions.py +12 -6
- nautobot/core/jobs/cleanup.py +13 -1
- nautobot/core/settings.py +6 -0
- nautobot/core/settings_funcs.py +11 -1
- nautobot/core/templates/widgets/number_input_with_choices.html +44 -0
- nautobot/core/templatetags/helpers.py +9 -7
- nautobot/core/tests/nautobot_config.py +3 -0
- nautobot/core/tests/test_jobs.py +118 -0
- nautobot/core/tests/test_templatetags_helpers.py +6 -0
- nautobot/core/tests/test_ui.py +49 -1
- nautobot/core/tests/test_utils.py +41 -1
- nautobot/core/ui/object_detail.py +7 -2
- nautobot/core/urls.py +7 -8
- nautobot/core/utils/filtering.py +11 -1
- nautobot/core/utils/lookup.py +46 -0
- nautobot/core/views/mixins.py +23 -17
- nautobot/core/views/utils.py +3 -3
- nautobot/dcim/api/serializers.py +3 -0
- nautobot/dcim/choices.py +49 -0
- nautobot/dcim/constants.py +7 -0
- nautobot/dcim/filters/__init__.py +7 -0
- nautobot/dcim/forms.py +89 -3
- nautobot/dcim/migrations/0075_interface_duplex_interface_speed_and_more.py +32 -0
- nautobot/dcim/models/device_component_templates.py +33 -1
- nautobot/dcim/models/device_components.py +21 -0
- nautobot/dcim/tables/devices.py +14 -0
- nautobot/dcim/tables/devicetypes.py +8 -1
- nautobot/dcim/templates/dcim/interface.html +8 -0
- nautobot/dcim/templates/dcim/interface_edit.html +2 -0
- nautobot/dcim/tests/test_api.py +186 -6
- nautobot/dcim/tests/test_filters.py +32 -0
- nautobot/dcim/tests/test_forms.py +110 -8
- nautobot/dcim/tests/test_graphql.py +44 -1
- nautobot/dcim/tests/test_models.py +265 -0
- nautobot/dcim/tests/test_tables.py +160 -0
- nautobot/dcim/tests/test_views.py +64 -1
- nautobot/dcim/views.py +86 -77
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/jobs.py +48 -2
- nautobot/extras/models/models.py +19 -0
- nautobot/extras/models/relationships.py +3 -1
- nautobot/extras/templates/extras/plugin_detail.html +2 -2
- nautobot/extras/urls.py +0 -14
- nautobot/extras/views.py +1 -1
- nautobot/ipam/ui.py +0 -17
- nautobot/ipam/views.py +2 -2
- nautobot/project-static/js/forms.js +92 -14
- nautobot/virtualization/tests/test_models.py +4 -2
- nautobot/virtualization/views.py +1 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/METADATA +4 -4
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/RECORD +62 -59
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/NOTICE +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/WHEEL +0 -0
- {nautobot-2.4.21.dist-info → nautobot-2.4.23.dist-info}/entry_points.txt +0 -0
nautobot/dcim/views.py
CHANGED
|
@@ -485,25 +485,31 @@ class LocationUIViewSet(NautobotUIViewSet):
|
|
|
485
485
|
)
|
|
486
486
|
|
|
487
487
|
def get_extra_context(self, request, instance):
|
|
488
|
-
|
|
489
|
-
return super().get_extra_context(request, instance)
|
|
488
|
+
context = super().get_extra_context(request, instance)
|
|
490
489
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
490
|
+
if self.action == "retrieve":
|
|
491
|
+
# This query can get really expensive when there are big location trees in the DB. By casting it to a list we
|
|
492
|
+
# ensure it is only performed once rather than as a subquery for each of the different count stats.
|
|
493
|
+
related_locations = list(
|
|
494
|
+
instance.descendants(include_self=True).restrict(request.user, "view").values_list("pk", flat=True)
|
|
495
|
+
)
|
|
496
496
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
497
|
+
rack_groups = (
|
|
498
|
+
RackGroup.objects.annotate(rack_count=count_related(Rack, "rack_group"))
|
|
499
|
+
.restrict(request.user, "view")
|
|
500
|
+
.filter(location__in=related_locations)
|
|
501
|
+
)
|
|
502
502
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
503
|
+
context.update(
|
|
504
|
+
{
|
|
505
|
+
"rack_groups": rack_groups,
|
|
506
|
+
"rack_count": Rack.objects.restrict(request.user, "view")
|
|
507
|
+
.filter(location__in=related_locations)
|
|
508
|
+
.count(),
|
|
509
|
+
}
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return context
|
|
507
513
|
|
|
508
514
|
|
|
509
515
|
class MigrateLocationDataToContactView(generic.ObjectEditView):
|
|
@@ -1480,65 +1486,68 @@ class ModuleTypeUIViewSet(
|
|
|
1480
1486
|
return super().get_required_permission()
|
|
1481
1487
|
|
|
1482
1488
|
def get_extra_context(self, request, instance):
|
|
1483
|
-
|
|
1484
|
-
|
|
1489
|
+
context = super().get_extra_context(request, instance)
|
|
1490
|
+
if self.action == "retrieve":
|
|
1491
|
+
instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count()
|
|
1485
1492
|
|
|
1486
|
-
|
|
1493
|
+
# Component tables
|
|
1494
|
+
consoleport_table = tables.ConsolePortTemplateTable(
|
|
1495
|
+
ConsolePortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1496
|
+
orderable=False,
|
|
1497
|
+
)
|
|
1498
|
+
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
|
|
1499
|
+
ConsoleServerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1500
|
+
orderable=False,
|
|
1501
|
+
)
|
|
1502
|
+
powerport_table = tables.PowerPortTemplateTable(
|
|
1503
|
+
PowerPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1504
|
+
orderable=False,
|
|
1505
|
+
)
|
|
1506
|
+
poweroutlet_table = tables.PowerOutletTemplateTable(
|
|
1507
|
+
PowerOutletTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1508
|
+
orderable=False,
|
|
1509
|
+
)
|
|
1510
|
+
interface_table = tables.InterfaceTemplateTable(
|
|
1511
|
+
list(InterfaceTemplate.objects.restrict(request.user, "view").filter(module_type=instance)),
|
|
1512
|
+
orderable=False,
|
|
1513
|
+
)
|
|
1514
|
+
front_port_table = tables.FrontPortTemplateTable(
|
|
1515
|
+
FrontPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1516
|
+
orderable=False,
|
|
1517
|
+
)
|
|
1518
|
+
rear_port_table = tables.RearPortTemplateTable(
|
|
1519
|
+
RearPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1520
|
+
orderable=False,
|
|
1521
|
+
)
|
|
1522
|
+
modulebay_table = tables.ModuleBayTemplateTable(
|
|
1523
|
+
ModuleBayTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1524
|
+
orderable=False,
|
|
1525
|
+
)
|
|
1526
|
+
if request.user.has_perm("dcim.change_moduletype"):
|
|
1527
|
+
consoleport_table.columns.show("pk")
|
|
1528
|
+
consoleserverport_table.columns.show("pk")
|
|
1529
|
+
powerport_table.columns.show("pk")
|
|
1530
|
+
poweroutlet_table.columns.show("pk")
|
|
1531
|
+
interface_table.columns.show("pk")
|
|
1532
|
+
front_port_table.columns.show("pk")
|
|
1533
|
+
rear_port_table.columns.show("pk")
|
|
1534
|
+
modulebay_table.columns.show("pk")
|
|
1487
1535
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
poweroutlet_table = tables.PowerOutletTemplateTable(
|
|
1502
|
-
PowerOutletTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1503
|
-
orderable=False,
|
|
1504
|
-
)
|
|
1505
|
-
interface_table = tables.InterfaceTemplateTable(
|
|
1506
|
-
list(InterfaceTemplate.objects.restrict(request.user, "view").filter(module_type=instance)),
|
|
1507
|
-
orderable=False,
|
|
1508
|
-
)
|
|
1509
|
-
front_port_table = tables.FrontPortTemplateTable(
|
|
1510
|
-
FrontPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1511
|
-
orderable=False,
|
|
1512
|
-
)
|
|
1513
|
-
rear_port_table = tables.RearPortTemplateTable(
|
|
1514
|
-
RearPortTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1515
|
-
orderable=False,
|
|
1516
|
-
)
|
|
1517
|
-
modulebay_table = tables.ModuleBayTemplateTable(
|
|
1518
|
-
ModuleBayTemplate.objects.restrict(request.user, "view").filter(module_type=instance),
|
|
1519
|
-
orderable=False,
|
|
1520
|
-
)
|
|
1521
|
-
if request.user.has_perm("dcim.change_moduletype"):
|
|
1522
|
-
consoleport_table.columns.show("pk")
|
|
1523
|
-
consoleserverport_table.columns.show("pk")
|
|
1524
|
-
powerport_table.columns.show("pk")
|
|
1525
|
-
poweroutlet_table.columns.show("pk")
|
|
1526
|
-
interface_table.columns.show("pk")
|
|
1527
|
-
front_port_table.columns.show("pk")
|
|
1528
|
-
rear_port_table.columns.show("pk")
|
|
1529
|
-
modulebay_table.columns.show("pk")
|
|
1536
|
+
context.update(
|
|
1537
|
+
{
|
|
1538
|
+
"instance_count": instance_count,
|
|
1539
|
+
"consoleport_table": consoleport_table,
|
|
1540
|
+
"consoleserverport_table": consoleserverport_table,
|
|
1541
|
+
"powerport_table": powerport_table,
|
|
1542
|
+
"poweroutlet_table": poweroutlet_table,
|
|
1543
|
+
"interface_table": interface_table,
|
|
1544
|
+
"front_port_table": front_port_table,
|
|
1545
|
+
"rear_port_table": rear_port_table,
|
|
1546
|
+
"modulebay_table": modulebay_table,
|
|
1547
|
+
}
|
|
1548
|
+
)
|
|
1530
1549
|
|
|
1531
|
-
return
|
|
1532
|
-
"instance_count": instance_count,
|
|
1533
|
-
"consoleport_table": consoleport_table,
|
|
1534
|
-
"consoleserverport_table": consoleserverport_table,
|
|
1535
|
-
"powerport_table": powerport_table,
|
|
1536
|
-
"poweroutlet_table": poweroutlet_table,
|
|
1537
|
-
"interface_table": interface_table,
|
|
1538
|
-
"front_port_table": front_port_table,
|
|
1539
|
-
"rear_port_table": rear_port_table,
|
|
1540
|
-
"modulebay_table": modulebay_table,
|
|
1541
|
-
}
|
|
1550
|
+
return context
|
|
1542
1551
|
|
|
1543
1552
|
@action(
|
|
1544
1553
|
detail=False,
|
|
@@ -2302,7 +2311,7 @@ class DeviceUIViewSet(NautobotUIViewSet):
|
|
|
2302
2311
|
|
|
2303
2312
|
def get_queryset(self):
|
|
2304
2313
|
queryset = super().get_queryset()
|
|
2305
|
-
if self.
|
|
2314
|
+
if self.action == "retrieve":
|
|
2306
2315
|
queryset = queryset.select_related(
|
|
2307
2316
|
"cluster__cluster_group",
|
|
2308
2317
|
"controller_managed_device_group__controller",
|
|
@@ -3062,7 +3071,7 @@ class DeviceUIViewSet(NautobotUIViewSet):
|
|
|
3062
3071
|
def get_extra_context(self, request, instance):
|
|
3063
3072
|
extra_context = super().get_extra_context(request, instance)
|
|
3064
3073
|
|
|
3065
|
-
if self.
|
|
3074
|
+
if self.action == "retrieve":
|
|
3066
3075
|
# VirtualChassis members
|
|
3067
3076
|
if instance.virtual_chassis is not None:
|
|
3068
3077
|
vc_members = (
|
|
@@ -5816,7 +5825,7 @@ class ControllerUIViewSet(NautobotUIViewSet):
|
|
|
5816
5825
|
object_detail.DistinctViewTab(
|
|
5817
5826
|
weight=700,
|
|
5818
5827
|
tab_id="wireless_networks",
|
|
5819
|
-
url_name="dcim:
|
|
5828
|
+
url_name="dcim:controller_wireless_networks",
|
|
5820
5829
|
label="Wireless Networks",
|
|
5821
5830
|
related_object_attribute="wireless_network_assignments",
|
|
5822
5831
|
panels=(
|
|
@@ -5840,12 +5849,12 @@ class ControllerUIViewSet(NautobotUIViewSet):
|
|
|
5840
5849
|
@action(
|
|
5841
5850
|
detail=True,
|
|
5842
5851
|
url_path="wireless-networks",
|
|
5843
|
-
url_name="
|
|
5852
|
+
url_name="wireless_networks",
|
|
5844
5853
|
methods=["get"],
|
|
5845
5854
|
custom_view_base_action="view",
|
|
5846
5855
|
custom_view_additional_permissions=["wireless.view_controllermanageddevicegroupwirelessnetworkassignment"],
|
|
5847
5856
|
)
|
|
5848
|
-
def
|
|
5857
|
+
def wireless_networks(self, request, *args, **kwargs):
|
|
5849
5858
|
return Response({})
|
|
5850
5859
|
|
|
5851
5860
|
|
nautobot/extras/forms/forms.py
CHANGED
|
@@ -386,7 +386,9 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
|
|
386
386
|
role = DynamicModelMultipleChoiceField(
|
|
387
387
|
queryset=Role.objects.get_for_models([Device, VirtualMachine]), to_field_name="name", required=False
|
|
388
388
|
)
|
|
389
|
-
|
|
389
|
+
device_type = DynamicModelMultipleChoiceField(
|
|
390
|
+
queryset=DeviceType.objects.all(), to_field_name="model", required=False
|
|
391
|
+
)
|
|
390
392
|
platform = DynamicModelMultipleChoiceField(queryset=Platform.objects.all(), to_field_name="name", required=False)
|
|
391
393
|
cluster_group = DynamicModelMultipleChoiceField(
|
|
392
394
|
queryset=ClusterGroup.objects.all(), to_field_name="name", required=False
|
nautobot/extras/jobs.py
CHANGED
|
@@ -29,6 +29,7 @@ from django.db.models.query import QuerySet
|
|
|
29
29
|
from django.forms import ValidationError
|
|
30
30
|
from django.utils.functional import classproperty
|
|
31
31
|
import netaddr
|
|
32
|
+
from prometheus_client import Counter
|
|
32
33
|
import yaml
|
|
33
34
|
|
|
34
35
|
from nautobot.core.celery import import_jobs, nautobot_task
|
|
@@ -88,6 +89,27 @@ __all__ = [
|
|
|
88
89
|
|
|
89
90
|
logger = logging.getLogger(__name__)
|
|
90
91
|
|
|
92
|
+
started_jobs_counter = Counter(
|
|
93
|
+
name="nautobot_worker_started_jobs",
|
|
94
|
+
documentation="Job executions that started running",
|
|
95
|
+
labelnames=("job_class_name", "module_name"),
|
|
96
|
+
)
|
|
97
|
+
finished_jobs_counter = Counter(
|
|
98
|
+
name="nautobot_worker_finished_jobs",
|
|
99
|
+
documentation="Job executions that finished running",
|
|
100
|
+
labelnames=("job_class_name", "module_name", "status"),
|
|
101
|
+
)
|
|
102
|
+
exception_jobs_counter = Counter(
|
|
103
|
+
name="nautobot_worker_exception_jobs",
|
|
104
|
+
documentation="Job executions that raised an exception",
|
|
105
|
+
labelnames=("job_class_name", "module_name", "exception_type"),
|
|
106
|
+
)
|
|
107
|
+
singleton_conflict_counter = Counter(
|
|
108
|
+
name="nautobot_worker_singleton_conflict",
|
|
109
|
+
documentation="Job executions that ran into a singleton lock",
|
|
110
|
+
labelnames=("job_class_name", "module_name"),
|
|
111
|
+
)
|
|
112
|
+
|
|
91
113
|
|
|
92
114
|
class RunJobTaskFailed(Exception):
|
|
93
115
|
"""Celery task failed for some reason."""
|
|
@@ -1198,6 +1220,9 @@ def _prepare_job(job_class_path, request, kwargs) -> tuple[Job, dict]:
|
|
|
1198
1220
|
extra={"object": job.job_model, "grouping": "initialization"},
|
|
1199
1221
|
)
|
|
1200
1222
|
else:
|
|
1223
|
+
singleton_conflict_counter.labels(
|
|
1224
|
+
job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
|
|
1225
|
+
).inc()
|
|
1201
1226
|
# TODO 3.0: maybe change to logger.failure() and return cleanly, as this is an "acceptable" failure?
|
|
1202
1227
|
job.logger.error(
|
|
1203
1228
|
"Job %s is a singleton and already running.",
|
|
@@ -1281,6 +1306,9 @@ def run_job(self, job_class_path, *args, **kwargs):
|
|
|
1281
1306
|
|
|
1282
1307
|
result = None
|
|
1283
1308
|
status = None
|
|
1309
|
+
started_jobs_counter.labels(
|
|
1310
|
+
job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name
|
|
1311
|
+
).inc()
|
|
1284
1312
|
try:
|
|
1285
1313
|
before_start_result = job.before_start(self.request.id, args, kwargs)
|
|
1286
1314
|
if not job._failed:
|
|
@@ -1297,6 +1325,9 @@ def run_job(self, job_class_path, *args, **kwargs):
|
|
|
1297
1325
|
job.on_success(result, self.request.id, args, kwargs)
|
|
1298
1326
|
else:
|
|
1299
1327
|
job.on_failure(result, self.request.id, args, kwargs, None)
|
|
1328
|
+
finished_jobs_counter.labels(
|
|
1329
|
+
job_class_name=job.job_model.job_class_name, module_name=job.job_model.module_name, status=status
|
|
1330
|
+
).inc()
|
|
1300
1331
|
|
|
1301
1332
|
job.after_return(status, result, self.request.id, args, kwargs, None)
|
|
1302
1333
|
|
|
@@ -1313,11 +1344,21 @@ def run_job(self, job_class_path, *args, **kwargs):
|
|
|
1313
1344
|
# We don't want to overwrite the manual state update that we did above, so:
|
|
1314
1345
|
raise Ignore()
|
|
1315
1346
|
|
|
1316
|
-
except Reject:
|
|
1347
|
+
except Reject as exc:
|
|
1348
|
+
exception_jobs_counter.labels(
|
|
1349
|
+
job_class_name=job.job_model.job_class_name,
|
|
1350
|
+
module_name=job.job_model.module_name,
|
|
1351
|
+
exception_type=type(exc).__name__,
|
|
1352
|
+
).inc()
|
|
1317
1353
|
status = status or JobResultStatusChoices.STATUS_REJECTED
|
|
1318
1354
|
raise
|
|
1319
1355
|
|
|
1320
|
-
except Ignore:
|
|
1356
|
+
except Ignore as exc:
|
|
1357
|
+
exception_jobs_counter.labels(
|
|
1358
|
+
job_class_name=job.job_model.job_class_name,
|
|
1359
|
+
module_name=job.job_model.module_name,
|
|
1360
|
+
exception_type=type(exc).__name__,
|
|
1361
|
+
).inc()
|
|
1321
1362
|
status = status or JobResultStatusChoices.STATUS_IGNORED
|
|
1322
1363
|
raise
|
|
1323
1364
|
|
|
@@ -1330,6 +1371,11 @@ def run_job(self, job_class_path, *args, **kwargs):
|
|
|
1330
1371
|
"exc_type": type(exc).__name__,
|
|
1331
1372
|
"exc_message": sanitize(str(exc)),
|
|
1332
1373
|
}
|
|
1374
|
+
exception_jobs_counter.labels(
|
|
1375
|
+
job_class_name=job.job_model.job_class_name,
|
|
1376
|
+
module_name=job.job_model.module_name,
|
|
1377
|
+
exception_type=type(exc).__name__,
|
|
1378
|
+
).inc()
|
|
1333
1379
|
raise
|
|
1334
1380
|
|
|
1335
1381
|
finally:
|
nautobot/extras/models/models.py
CHANGED
|
@@ -26,6 +26,7 @@ from nautobot.core.models import BaseManager, BaseModel
|
|
|
26
26
|
from nautobot.core.models.fields import ForeignKeyWithAutoRelatedName, LaxURLField
|
|
27
27
|
from nautobot.core.models.generics import OrganizationalModel, PrimaryModel
|
|
28
28
|
from nautobot.core.utils.data import deepmerge, render_jinja2
|
|
29
|
+
from nautobot.core.utils.lookup import get_filterset_for_model, get_model_for_view_name
|
|
29
30
|
from nautobot.extras.choices import (
|
|
30
31
|
ButtonClassChoices,
|
|
31
32
|
WebhookHttpMethodChoices,
|
|
@@ -903,6 +904,24 @@ class SavedView(BaseModel, ChangeLoggedModel):
|
|
|
903
904
|
|
|
904
905
|
super().save(*args, **kwargs)
|
|
905
906
|
|
|
907
|
+
@property
|
|
908
|
+
def model(self):
|
|
909
|
+
"""
|
|
910
|
+
Return the model class associated with this SavedView, based on the 'view' field.
|
|
911
|
+
"""
|
|
912
|
+
return get_model_for_view_name(self.view)
|
|
913
|
+
|
|
914
|
+
def get_filtered_queryset(self, user):
|
|
915
|
+
"""
|
|
916
|
+
Return a queryset for the associated model, filtered by this SavedView's filter_params.
|
|
917
|
+
"""
|
|
918
|
+
model = self.model
|
|
919
|
+
if model is None:
|
|
920
|
+
return None
|
|
921
|
+
filter_params = self.config.get("filter_params", {})
|
|
922
|
+
filterset_class = get_filterset_for_model(model)
|
|
923
|
+
return filterset_class(filter_params, model.objects.restrict(user)).qs
|
|
924
|
+
|
|
906
925
|
|
|
907
926
|
@extras_features("graphql")
|
|
908
927
|
class UserSavedViewAssociation(BaseModel):
|
|
@@ -284,7 +284,9 @@ class RelationshipModel(models.Model):
|
|
|
284
284
|
Q(**side_query_params) | Q(**peer_side_query_params)
|
|
285
285
|
).distinct()
|
|
286
286
|
if not relationship.has_many(peer_side):
|
|
287
|
-
resp[
|
|
287
|
+
resp[RelationshipSideChoices.SIDE_PEER][relationship] = resp[
|
|
288
|
+
RelationshipSideChoices.SIDE_PEER
|
|
289
|
+
][relationship].first()
|
|
288
290
|
else:
|
|
289
291
|
# Maybe an uninstalled App?
|
|
290
292
|
# We can't provide a relevant queryset, but we can provide a descriptive string
|
|
@@ -83,11 +83,11 @@
|
|
|
83
83
|
<table class="table table-hover panel-body attr-table">
|
|
84
84
|
<tr>
|
|
85
85
|
<td>Min Nautobot Version</td>
|
|
86
|
-
<td>v{{ object.min_version |
|
|
86
|
+
<td>{% if object.min_version %}v{{ object.min_version }}{% else %}{{ None|placeholder }}{% endif %}</td>
|
|
87
87
|
</tr>
|
|
88
88
|
<tr>
|
|
89
89
|
<td>Max Nautobot Version</td>
|
|
90
|
-
<td>v{{ object.max_version |
|
|
90
|
+
<td>{% if object.max_version %}v{{ object.max_version }}{% else %}{{ None|placeholder }}{% endif %}</td>
|
|
91
91
|
</tr>
|
|
92
92
|
</table>
|
|
93
93
|
</div>
|
nautobot/extras/urls.py
CHANGED
|
@@ -4,7 +4,6 @@ from nautobot.core.views.routers import NautobotUIViewSetRouter
|
|
|
4
4
|
from nautobot.extras import views
|
|
5
5
|
from nautobot.extras.models import (
|
|
6
6
|
Job,
|
|
7
|
-
Relationship,
|
|
8
7
|
)
|
|
9
8
|
|
|
10
9
|
app_name = "extras"
|
|
@@ -111,19 +110,6 @@ urlpatterns = [
|
|
|
111
110
|
path("jobs/<str:class_path>/run/", views.JobRunView.as_view(), name="job_run_by_class_path"),
|
|
112
111
|
path("jobs/edit/", views.JobBulkEditView.as_view(), name="job_bulk_edit"),
|
|
113
112
|
path("jobs/delete/", views.JobBulkDeleteView.as_view(), name="job_bulk_delete"),
|
|
114
|
-
# Custom relationships
|
|
115
|
-
path(
|
|
116
|
-
"relationships/<uuid:pk>/changelog/",
|
|
117
|
-
views.ObjectChangeLogView.as_view(),
|
|
118
|
-
name="relationship_changelog",
|
|
119
|
-
kwargs={"model": Relationship},
|
|
120
|
-
),
|
|
121
|
-
path(
|
|
122
|
-
"relationships/<uuid:pk>/notes/",
|
|
123
|
-
views.ObjectNotesView.as_view(),
|
|
124
|
-
name="relationship_notes",
|
|
125
|
-
kwargs={"model": Relationship},
|
|
126
|
-
),
|
|
127
113
|
# Secrets
|
|
128
114
|
path(
|
|
129
115
|
"secrets/provider/<str:provider_slug>/form/",
|
nautobot/extras/views.py
CHANGED
|
@@ -1194,7 +1194,7 @@ class GitRepositoryUIViewSet(NautobotUIViewSet):
|
|
|
1194
1194
|
context = {
|
|
1195
1195
|
**super().get_extra_context(request, instance),
|
|
1196
1196
|
"result": job_result or {},
|
|
1197
|
-
"base_template": "extras/
|
|
1197
|
+
"base_template": "extras/gitrepository_retrieve.html",
|
|
1198
1198
|
"object": instance,
|
|
1199
1199
|
"active_tab": "result",
|
|
1200
1200
|
"verbose_name": instance._meta.verbose_name,
|
nautobot/ipam/ui.py
CHANGED
|
@@ -10,29 +10,12 @@ from nautobot.core.ui.object_detail import (
|
|
|
10
10
|
Button,
|
|
11
11
|
KeyValueTablePanel,
|
|
12
12
|
ObjectFieldsPanel,
|
|
13
|
-
ObjectsTablePanel,
|
|
14
13
|
)
|
|
15
14
|
from nautobot.core.views.utils import get_obj_from_context
|
|
16
15
|
|
|
17
16
|
logger = logging.getLogger(__name__)
|
|
18
17
|
|
|
19
18
|
|
|
20
|
-
# TODO: can be removed as a part of NAUTOBOT-1051
|
|
21
|
-
class PrefixChildTablePanel(ObjectsTablePanel):
|
|
22
|
-
def should_render(self, context: Context):
|
|
23
|
-
if not super().should_render(context):
|
|
24
|
-
return False
|
|
25
|
-
return context.get("active_tab") == "prefixes"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# TODO: can be removed as a part of NAUTOBOT-1051
|
|
29
|
-
class IPAddressTablePanel(ObjectsTablePanel):
|
|
30
|
-
def should_render(self, context: Context):
|
|
31
|
-
if not super().should_render(context):
|
|
32
|
-
return False
|
|
33
|
-
return context.get("active_tab") == "ip-addresses"
|
|
34
|
-
|
|
35
|
-
|
|
36
19
|
class AddChildPrefixButton(Button):
|
|
37
20
|
"""Custom button to add a child prefix inside a Prefix detail view."""
|
|
38
21
|
|
nautobot/ipam/views.py
CHANGED
|
@@ -432,7 +432,7 @@ class PrefixUIViewSet(NautobotUIViewSet):
|
|
|
432
432
|
related_object_attribute="default_descendants",
|
|
433
433
|
url_name="ipam:prefix_prefixes",
|
|
434
434
|
panels=(
|
|
435
|
-
|
|
435
|
+
object_detail.ObjectsTablePanel(
|
|
436
436
|
section=SectionChoices.FULL_WIDTH,
|
|
437
437
|
weight=100,
|
|
438
438
|
context_table_key="prefix_table",
|
|
@@ -449,7 +449,7 @@ class PrefixUIViewSet(NautobotUIViewSet):
|
|
|
449
449
|
related_object_attribute="all_ips",
|
|
450
450
|
url_name="ipam:prefix_ipaddresses",
|
|
451
451
|
panels=[
|
|
452
|
-
|
|
452
|
+
object_detail.ObjectsTablePanel(
|
|
453
453
|
section=SectionChoices.FULL_WIDTH,
|
|
454
454
|
weight=100,
|
|
455
455
|
context_table_key="ip_table",
|
|
@@ -223,6 +223,19 @@ function initializeFormActionClick(context){
|
|
|
223
223
|
function initializeBulkEditNullification(context){
|
|
224
224
|
this_context = $(context);
|
|
225
225
|
this_context.find('input:checkbox[name=_nullify]').click(function() {
|
|
226
|
+
var $field = $('#id_' + this.value);
|
|
227
|
+
|
|
228
|
+
// If this is a NumberWithSelect (input-group + caret menu), don't hide the
|
|
229
|
+
// field. Some other fields (e.g.Interface: LAG, Bridge) currently do nothing
|
|
230
|
+
// when _nullify is checked, so this is consistent.
|
|
231
|
+
var $group = $field.closest('.input-group');
|
|
232
|
+
var isNumberWithSelect = $group.length &&
|
|
233
|
+
$group.find('.input-group-btn .dropdown-menu a.set_value').length > 0;
|
|
234
|
+
if (isNumberWithSelect) {
|
|
235
|
+
return; // no UI change; _nullify still submitted
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Existing behavior for other fields
|
|
226
239
|
$('#id_' + this.value).toggle('disabled');
|
|
227
240
|
});
|
|
228
241
|
}
|
|
@@ -590,19 +603,57 @@ function initializeVLANModeSelection(context){
|
|
|
590
603
|
|
|
591
604
|
function initializeMultiValueChar(context, dropdownParent=null){
|
|
592
605
|
this_context = $(context);
|
|
593
|
-
this_context.find('.nautobot-select2-multi-value-char').
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
"
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
+
this_context.find('.nautobot-select2-multi-value-char').each(function(){
|
|
607
|
+
var $el = $(this);
|
|
608
|
+
$el.select2({
|
|
609
|
+
allowClear: true,
|
|
610
|
+
tags: true,
|
|
611
|
+
theme: "bootstrap",
|
|
612
|
+
placeholder: "---------",
|
|
613
|
+
multiple: true,
|
|
614
|
+
dropdownParent: dropdownParent,
|
|
615
|
+
width: "off",
|
|
616
|
+
tokenSeparators: [',', ' '],
|
|
617
|
+
"language": {
|
|
618
|
+
"noResults": function(){
|
|
619
|
+
return "Type something to add it as an option";
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Ensure pressing Enter in the Select2 search adds the current token instead of submitting the form
|
|
625
|
+
$el.on('select2:open', function(){
|
|
626
|
+
const container = document.querySelector('.select2-container--open');
|
|
627
|
+
if (!container) return;
|
|
628
|
+
const search = container.querySelector('input.select2-search__field');
|
|
629
|
+
if (!search) return;
|
|
630
|
+
|
|
631
|
+
// Avoid stacking multiple handlers
|
|
632
|
+
if (search.getAttribute('data-enter-binds')) return;
|
|
633
|
+
search.setAttribute('data-enter-binds', '1');
|
|
634
|
+
|
|
635
|
+
search.addEventListener('keydown', function(e){
|
|
636
|
+
if (e.key === 'Enter'){
|
|
637
|
+
e.preventDefault();
|
|
638
|
+
e.stopPropagation();
|
|
639
|
+
const val = this.value.trim();
|
|
640
|
+
if (!val) return;
|
|
641
|
+
const sel = $el.get(0);
|
|
642
|
+
// If option doesn't exist, create it; otherwise select it
|
|
643
|
+
let found = Array.prototype.find.call(sel.options, function(opt){ return String(opt.value) === String(val); });
|
|
644
|
+
if (!found) {
|
|
645
|
+
sel.add(new Option(val, val, true, true));
|
|
646
|
+
} else {
|
|
647
|
+
found.selected = true;
|
|
648
|
+
}
|
|
649
|
+
// Clear the search box and notify Select2
|
|
650
|
+
this.value = '';
|
|
651
|
+
$($el).trigger('change');
|
|
652
|
+
// Close the dropdown so it doesn't linger after add
|
|
653
|
+
try { $el.select2('close'); } catch (e) {}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
});
|
|
606
657
|
});
|
|
607
658
|
}
|
|
608
659
|
|
|
@@ -714,7 +765,34 @@ function initializeDynamicFilterForm(context){
|
|
|
714
765
|
lookup_type_value = $(this).find(".lookup_type-select").val();
|
|
715
766
|
lookup_value = $(this).find(".lookup_value-input");
|
|
716
767
|
lookup_value.attr("name", lookup_type_value);
|
|
717
|
-
})
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Pre-populate filter selects (default + advanced) from current URL query params,
|
|
771
|
+
// including free-form values that are not part of the preset choices.
|
|
772
|
+
(function prepopulateFilterSelectsFromURL(){
|
|
773
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
774
|
+
// Only target Select2 tag controls inside the filter UI (default sidebar and advanced modal).
|
|
775
|
+
// Avoids touching unrelated Select2 tagging fields elsewhere on the page (e.g., tags inputs).
|
|
776
|
+
const selector = '#default-filter form select.nautobot-select2-multi-value-char, #advanced-filter select.nautobot-select2-multi-value-char';
|
|
777
|
+
this_context.find(selector).each(function(){
|
|
778
|
+
const sel = this;
|
|
779
|
+
const name = sel.getAttribute('name');
|
|
780
|
+
if (!name) { return; }
|
|
781
|
+
const values = urlParams.getAll(name);
|
|
782
|
+
if (!values.length) { return; }
|
|
783
|
+
values.forEach(function(v){
|
|
784
|
+
let found = Array.prototype.find.call(sel.options, function(opt){ return String(opt.value) === String(v); });
|
|
785
|
+
if (!found) {
|
|
786
|
+
sel.add(new Option(v, v, true, true));
|
|
787
|
+
} else {
|
|
788
|
+
found.selected = true;
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
if (window.jQuery && $(sel).data('select2')) {
|
|
792
|
+
$(sel).trigger('change');
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
})();
|
|
718
796
|
|
|
719
797
|
// Remove applied filters
|
|
720
798
|
this_context.find(".remove-filter-param").on("click", function(){
|
|
@@ -131,6 +131,7 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
|
|
|
131
131
|
name="Int1", virtual_machine=self.virtualmachine, status=self.int_status
|
|
132
132
|
)
|
|
133
133
|
ips = list(IPAddress.objects.all()[:10])
|
|
134
|
+
self.assertEqual(len(ips), 10)
|
|
134
135
|
|
|
135
136
|
# baseline (no vm_interface to ip address relationships exists)
|
|
136
137
|
self.assertFalse(IPAddressToInterface.objects.filter(vm_interface=vm_interface).exists())
|
|
@@ -171,7 +172,8 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
|
|
|
171
172
|
vm_interface = VMInterface.objects.create(
|
|
172
173
|
name="Int1", virtual_machine=self.virtualmachine, status=self.int_status
|
|
173
174
|
)
|
|
174
|
-
ips = list(IPAddress.objects.
|
|
175
|
+
ips = list(IPAddress.objects.filter(ip_version=4)[:10])
|
|
176
|
+
self.assertEqual(len(ips), 10)
|
|
175
177
|
|
|
176
178
|
# baseline (no vm_interface to ip address relationships exists)
|
|
177
179
|
self.assertFalse(IPAddressToInterface.objects.filter(vm_interface=vm_interface).exists())
|
|
@@ -219,7 +221,7 @@ class VMInterfaceTestCase(TestCase): # TODO: change to BaseModelTestCase
|
|
|
219
221
|
self.virtualmachine.refresh_from_db()
|
|
220
222
|
self.assertEqual(self.virtualmachine.primary_ip4, None)
|
|
221
223
|
# NOTE: This effectively tests what happens when you pass remove_ip_addresses None; it
|
|
222
|
-
# NOTE: does not remove a v6 address, because there are no v6 IPs
|
|
224
|
+
# NOTE: does not remove a v6 address, because there are no v6 IPs used in this test
|
|
223
225
|
# NOTE: class.
|
|
224
226
|
count = vm_interface.remove_ip_addresses(self.virtualmachine.primary_ip6)
|
|
225
227
|
self.assertEqual(count, 0)
|
nautobot/virtualization/views.py
CHANGED
|
@@ -379,6 +379,7 @@ class VirtualMachineUIViewSet(NautobotUIViewSet):
|
|
|
379
379
|
detail=True,
|
|
380
380
|
url_path="config-context",
|
|
381
381
|
url_name="configcontext",
|
|
382
|
+
custom_view_base_action="view",
|
|
382
383
|
custom_view_additional_permissions=["extras.view_configcontext"],
|
|
383
384
|
)
|
|
384
385
|
def config_context(self, request, pk):
|