nautobot 2.3.10__py3-none-any.whl → 2.3.11__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/utils.py +2 -0
- nautobot/cloud/tables.py +1 -0
- nautobot/core/forms/forms.py +5 -1
- nautobot/core/tables.py +88 -22
- nautobot/core/templates/generic/object_bulk_destroy.html +12 -3
- nautobot/core/templates/generic/object_bulk_update.html +4 -2
- nautobot/core/templates/generic/object_create.html +1 -1
- nautobot/core/templates/rest_framework/api.html +3 -0
- nautobot/core/testing/api.py +3 -1
- nautobot/core/testing/integration.py +64 -0
- nautobot/core/testing/views.py +33 -27
- nautobot/core/tests/integration/test_app_navbar.py +3 -3
- nautobot/core/tests/integration/test_navbar.py +1 -1
- nautobot/core/tests/test_csv.py +3 -0
- nautobot/core/tests/test_utils.py +25 -5
- nautobot/core/utils/lookup.py +35 -0
- nautobot/core/views/generic.py +50 -39
- nautobot/core/views/mixins.py +97 -43
- nautobot/core/views/renderers.py +8 -5
- nautobot/dcim/tables/devices.py +3 -0
- nautobot/dcim/templates/dcim/device_component_add.html +8 -8
- nautobot/dcim/templates/dcim/virtualchassis_add_member.html +2 -2
- nautobot/dcim/templates/dcim/virtualchassis_edit.html +2 -2
- nautobot/dcim/tests/integration/test_create_device.py +86 -0
- nautobot/extras/tests/test_relationships.py +1 -0
- nautobot/extras/views.py +1 -0
- nautobot/ipam/factory.py +3 -0
- nautobot/ipam/filters.py +5 -0
- nautobot/ipam/forms.py +17 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/signals.py +2 -2
- nautobot/ipam/tables.py +3 -3
- nautobot/ipam/templates/ipam/ipaddress_assign.html +2 -2
- nautobot/ipam/tests/test_models.py +113 -1
- nautobot/ipam/tests/test_views.py +39 -5
- nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +131 -6
- nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +175 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +94 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/views.html +4 -4
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +293 -138
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +270 -270
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +39 -0
- nautobot/virtualization/forms.py +24 -0
- nautobot/virtualization/templates/virtualization/vminterface_edit.html +1 -0
- nautobot/virtualization/tests/test_views.py +7 -2
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/METADATA +1 -1
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/RECORD +54 -53
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/NOTICE +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/WHEEL +0 -0
- {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/entry_points.txt +0 -0
nautobot/apps/utils.py
CHANGED
|
@@ -30,6 +30,7 @@ from nautobot.core.utils.lookup import (
|
|
|
30
30
|
get_form_for_model,
|
|
31
31
|
get_model_from_name,
|
|
32
32
|
get_related_class_for_model,
|
|
33
|
+
get_related_field_for_models,
|
|
33
34
|
get_route_for_model,
|
|
34
35
|
get_table_for_model,
|
|
35
36
|
get_url_for_url_pattern,
|
|
@@ -111,6 +112,7 @@ __all__ = (
|
|
|
111
112
|
"get_only_new_ui_ready_routes",
|
|
112
113
|
"get_permission_for_model",
|
|
113
114
|
"get_related_class_for_model",
|
|
115
|
+
"get_related_field_for_models",
|
|
114
116
|
"get_route_for_model",
|
|
115
117
|
"get_settings_or_config",
|
|
116
118
|
"get_table_for_model",
|
nautobot/cloud/tables.py
CHANGED
|
@@ -55,6 +55,7 @@ class CloudNetworkTable(BaseTable):
|
|
|
55
55
|
circuit_count = LinkedCountColumn(
|
|
56
56
|
viewname="circuits:circuit_list",
|
|
57
57
|
url_params={"cloud_network": "name"},
|
|
58
|
+
# lookup="circuit_terminations__circuit", # TODO: not currently supported
|
|
58
59
|
verbose_name="Circuits",
|
|
59
60
|
reverse_lookup="circuit_terminations__cloud_network",
|
|
60
61
|
)
|
nautobot/core/forms/forms.py
CHANGED
|
@@ -113,7 +113,7 @@ class BulkEditForm(forms.Form):
|
|
|
113
113
|
a more powerful subclass and should be used instead of directly inheriting from this class.
|
|
114
114
|
"""
|
|
115
115
|
|
|
116
|
-
def __init__(self, model, *args, **kwargs):
|
|
116
|
+
def __init__(self, model, *args, edit_all=False, **kwargs):
|
|
117
117
|
super().__init__(*args, **kwargs)
|
|
118
118
|
self.model = model
|
|
119
119
|
self.nullable_fields = []
|
|
@@ -122,6 +122,10 @@ class BulkEditForm(forms.Form):
|
|
|
122
122
|
if hasattr(self.Meta, "nullable_fields"):
|
|
123
123
|
self.nullable_fields = self.Meta.nullable_fields
|
|
124
124
|
|
|
125
|
+
if edit_all:
|
|
126
|
+
self.fields["pk"].required = False
|
|
127
|
+
self.fields["_all"] = forms.BooleanField(widget=forms.HiddenInput(), required=True, initial=True)
|
|
128
|
+
|
|
125
129
|
|
|
126
130
|
class BulkRenameForm(forms.Form):
|
|
127
131
|
"""
|
nautobot/core/tables.py
CHANGED
|
@@ -5,10 +5,12 @@ from django.contrib.auth.models import AnonymousUser
|
|
|
5
5
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
6
6
|
from django.core.exceptions import FieldDoesNotExist, FieldError
|
|
7
7
|
from django.db import NotSupportedError
|
|
8
|
+
from django.db.models import Prefetch
|
|
8
9
|
from django.db.models.fields.related import ForeignKey, RelatedField
|
|
9
10
|
from django.db.models.fields.reverse_related import ManyToOneRel
|
|
10
11
|
from django.urls import reverse
|
|
11
12
|
from django.utils.html import escape, format_html, format_html_join
|
|
13
|
+
from django.utils.http import urlencode
|
|
12
14
|
from django.utils.safestring import mark_safe
|
|
13
15
|
from django.utils.text import Truncator
|
|
14
16
|
import django_tables2
|
|
@@ -18,7 +20,7 @@ from tree_queries.models import TreeNode
|
|
|
18
20
|
|
|
19
21
|
from nautobot.core.models.querysets import count_related
|
|
20
22
|
from nautobot.core.templatetags import helpers
|
|
21
|
-
from nautobot.core.utils import
|
|
23
|
+
from nautobot.core.utils.lookup import get_model_for_view_name, get_related_field_for_models, get_route_for_model
|
|
22
24
|
from nautobot.extras import choices, models
|
|
23
25
|
|
|
24
26
|
logger = logging.getLogger(__name__)
|
|
@@ -154,12 +156,25 @@ class BaseTable(django_tables2.Table):
|
|
|
154
156
|
if not column.visible:
|
|
155
157
|
continue
|
|
156
158
|
if isinstance(column.column, LinkedCountColumn):
|
|
157
|
-
column_model =
|
|
159
|
+
column_model = get_model_for_view_name(column.column.viewname)
|
|
158
160
|
if column_model is None:
|
|
159
161
|
logger.error("Couldn't find model for %s", column.column.viewname)
|
|
160
162
|
continue
|
|
161
163
|
reverse_lookup = column.column.reverse_lookup or next(iter(column.column.url_params.keys()))
|
|
162
164
|
count_fields.append((column.name, column_model, reverse_lookup))
|
|
165
|
+
try:
|
|
166
|
+
lookup = column.column.lookup or get_related_field_for_models(model, column_model).name
|
|
167
|
+
# For some reason get_related_field_for_models(Tag, DynamicGroup) gives a M2M with the name
|
|
168
|
+
# `dynamicgroup`, which isn't actually a field on Tag. May be a django-taggit issue?
|
|
169
|
+
# Workaround for now: make sure the field actually exists on the model under this name:
|
|
170
|
+
getattr(model, lookup)
|
|
171
|
+
except AttributeError:
|
|
172
|
+
lookup = None
|
|
173
|
+
if lookup is not None:
|
|
174
|
+
# Also attempt to prefetch the first matching record for display - see LinkedCountColumn
|
|
175
|
+
prefetch_fields.append(
|
|
176
|
+
Prefetch(lookup, column_model.objects.all()[:1], to_attr=f"{lookup}_list")
|
|
177
|
+
)
|
|
163
178
|
continue
|
|
164
179
|
|
|
165
180
|
column_model = model
|
|
@@ -233,7 +248,7 @@ class BaseTable(django_tables2.Table):
|
|
|
233
248
|
# Belt and suspenders - we should have avoided any error cases above, but be safe anyway:
|
|
234
249
|
try:
|
|
235
250
|
queryset = queryset.prefetch_related(*prefetch_fields)
|
|
236
|
-
except (TypeError, ValueError, NotSupportedError) as exc:
|
|
251
|
+
except (AttributeError, TypeError, ValueError, NotSupportedError) as exc:
|
|
237
252
|
logger.warning(
|
|
238
253
|
"Unexpected error when trying to .prefetch_related() on %s QuerySet: %s",
|
|
239
254
|
model.__name__,
|
|
@@ -395,9 +410,9 @@ class ButtonsColumn(django_tables2.TemplateColumn):
|
|
|
395
410
|
self.template_code = prepend_template + self.template_code
|
|
396
411
|
|
|
397
412
|
app_label = model._meta.app_label
|
|
398
|
-
changelog_route =
|
|
399
|
-
edit_route =
|
|
400
|
-
delete_route =
|
|
413
|
+
changelog_route = get_route_for_model(model, "changelog")
|
|
414
|
+
edit_route = get_route_for_model(model, "edit")
|
|
415
|
+
delete_route = get_route_for_model(model, "delete")
|
|
401
416
|
|
|
402
417
|
template_code = self.template_code.format(
|
|
403
418
|
app_label=app_label,
|
|
@@ -462,29 +477,80 @@ class ColoredLabelColumn(django_tables2.TemplateColumn):
|
|
|
462
477
|
|
|
463
478
|
class LinkedCountColumn(django_tables2.Column):
|
|
464
479
|
"""
|
|
465
|
-
Render a count of related objects linked to a filtered URL.
|
|
466
|
-
|
|
467
|
-
:
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
480
|
+
Render a count of related objects linked to a filtered URL, or if a single related object is present, the object.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
viewname (str): The list view name to use for URL resolution, for example `"dcim:location_list"`
|
|
484
|
+
url_params (dict, optional): Query parameters to apply to filter the list URL (e.g. `{"vlans": "pk"}` will add
|
|
485
|
+
`?vlans=<record.pk>` to the linked list URL)
|
|
486
|
+
view_kwargs (dict, optional): Additional kwargs to pass to `reverse()` for list URL resolution. Rarely used.
|
|
487
|
+
lookup (str, optional): The field name on the base record that can be used to query the related objects.
|
|
488
|
+
If not specified, `nautobot.core.utils.lookup.get_related_field_for_models()` will be called at render time
|
|
489
|
+
to attempt to intelligently find the appropriate field.
|
|
490
|
+
TODO: this currently does *not* support nested lookups via `__`. That may be solvable in the future.
|
|
491
|
+
reverse_lookup (str, optional): The reverse lookup parameter to use to derive the count.
|
|
492
|
+
If not specified, the first key in `url_params` will be implicitly used as the `reverse_lookup` value.
|
|
493
|
+
**kwargs (dict, optional): As the parent Column class.
|
|
494
|
+
|
|
495
|
+
Examples:
|
|
496
|
+
```py
|
|
497
|
+
class VLANTable(..., BaseTable):
|
|
498
|
+
...
|
|
499
|
+
location_count = LinkedCountColumn(
|
|
500
|
+
# Link for N related locations will be reverse("dcim:location_list") + "?vlans=<record.pk>"
|
|
501
|
+
viewname="dcim:location_list",
|
|
502
|
+
url_params={"vlans": "pk"},
|
|
503
|
+
verbose_name="Locations",
|
|
504
|
+
)
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
```py
|
|
508
|
+
class CloudNetworkTable(BaseTable):
|
|
509
|
+
...
|
|
510
|
+
circuit_count = LinkedCountColumn(
|
|
511
|
+
# Link for N related circuits will be reverse("circuits:circuit_list") + "?cloud_network=<record.name>"
|
|
512
|
+
viewname="circuits:circuit_list",
|
|
513
|
+
url_params={"cloud_network": "name"},
|
|
514
|
+
# We'd like to do the below but this module isn't currently smart enough to build the right Prefetch()
|
|
515
|
+
# for a nested lookup:
|
|
516
|
+
# lookup="circuit_terminations__circuit",
|
|
517
|
+
# For the count, .annotate(circuit_count=count_related(Circuit, "circuit_terminations__cloud_network"))
|
|
518
|
+
reverse_lookup="circuit_terminations__cloud_network",
|
|
519
|
+
verbose_name="Circuits",
|
|
520
|
+
)
|
|
521
|
+
```
|
|
472
522
|
"""
|
|
473
523
|
|
|
474
|
-
def __init__(
|
|
524
|
+
def __init__(
|
|
525
|
+
self, viewname, *args, view_kwargs=None, url_params=None, lookup=None, reverse_lookup=None, default=0, **kwargs
|
|
526
|
+
):
|
|
475
527
|
self.viewname = viewname
|
|
528
|
+
self.lookup = lookup
|
|
476
529
|
self.view_kwargs = view_kwargs or {}
|
|
477
530
|
self.url_params = url_params
|
|
478
|
-
self.reverse_lookup = reverse_lookup
|
|
531
|
+
self.reverse_lookup = reverse_lookup or next(iter(url_params.keys()))
|
|
532
|
+
self.model = get_model_for_view_name(self.viewname)
|
|
479
533
|
super().__init__(*args, default=default, **kwargs)
|
|
480
534
|
|
|
481
|
-
def render(self, record, value): # pylint: disable=arguments-differ
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
535
|
+
def render(self, bound_column, record, value): # pylint: disable=arguments-differ
|
|
536
|
+
related_record = None
|
|
537
|
+
try:
|
|
538
|
+
lookup = self.lookup or get_related_field_for_models(bound_column._table._meta.model, self.model).name
|
|
539
|
+
except AttributeError:
|
|
540
|
+
lookup = None
|
|
541
|
+
if lookup:
|
|
542
|
+
if related_records := getattr(record, f"{lookup}_list", None):
|
|
543
|
+
related_record = related_records[0]
|
|
544
|
+
url = reverse(self.viewname, kwargs=self.view_kwargs)
|
|
545
|
+
if self.url_params:
|
|
546
|
+
url += "?" + urlencode({k: getattr(record, v) for k, v in self.url_params.items()})
|
|
547
|
+
if value > 1:
|
|
548
|
+
return format_html('<a href="{}" class="badge">{}</a>', url, value)
|
|
549
|
+
if related_record is not None:
|
|
550
|
+
return helpers.hyperlinked_object(related_record)
|
|
551
|
+
if value == 1:
|
|
552
|
+
return format_html('<a href="{}" class="badge">{}</a>', url, value)
|
|
553
|
+
return helpers.placeholder(value)
|
|
488
554
|
|
|
489
555
|
|
|
490
556
|
class TagColumn(django_tables2.TemplateColumn):
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{% load helpers %}
|
|
3
3
|
{% load render_table from django_tables2 %}
|
|
4
4
|
|
|
5
|
-
{% block title %}Delete {{
|
|
5
|
+
{% block title %}Delete {{ total_objs_to_delete }} {{ obj_type_plural|bettertitle }}?{% endblock %}
|
|
6
6
|
|
|
7
7
|
{% block content %}
|
|
8
8
|
<div class="row">
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
<div class="panel panel-danger">
|
|
11
11
|
<div class="panel-heading"><strong>Confirm Bulk Deletion</strong></div>
|
|
12
12
|
<div class="panel-body">
|
|
13
|
-
<p><strong>Warning:</strong> The following operation will delete {{
|
|
13
|
+
<p><strong>Warning:</strong> The following operation will delete {{ total_objs_to_delete }} {{ obj_type_plural }}. {% if not delete_all %}Please carefully review the {{ obj_type_plural }} to be deleted and confirm below.{% endif %}</p>
|
|
14
14
|
{% block message_extra %}{% endblock %}
|
|
15
15
|
</div>
|
|
16
16
|
</div>
|
|
17
17
|
</div>
|
|
18
18
|
</div>
|
|
19
|
+
|
|
20
|
+
{% if table %}
|
|
19
21
|
<div class="row">
|
|
20
22
|
<div class="col-md-8 col-md-offset-2">
|
|
21
23
|
<div class="panel panel-default">
|
|
@@ -25,15 +27,22 @@
|
|
|
25
27
|
</div>
|
|
26
28
|
</div>
|
|
27
29
|
</div>
|
|
30
|
+
{% endif %}
|
|
31
|
+
|
|
28
32
|
<div class="row">
|
|
29
33
|
<div class="col-md-6 col-md-offset-3">
|
|
30
34
|
<form action="" method="post" class="form">
|
|
35
|
+
|
|
31
36
|
{% csrf_token %}
|
|
37
|
+
{% if delete_all %}
|
|
38
|
+
<input type="hidden" name="_all" value="true" />
|
|
39
|
+
{% endif %}
|
|
32
40
|
{% for field in form.hidden_fields %}
|
|
33
41
|
{{ field }}
|
|
34
42
|
{% endfor %}
|
|
43
|
+
|
|
35
44
|
<div class="text-center">
|
|
36
|
-
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{
|
|
45
|
+
<button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ total_objs_to_delete }} {{ obj_type_plural }}</button>
|
|
37
46
|
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
|
38
47
|
</div>
|
|
39
48
|
</form>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
{% load render_table from django_tables2 %}
|
|
5
5
|
|
|
6
6
|
{% block content %}
|
|
7
|
-
<h1>{% block title %}Editing {{
|
|
7
|
+
<h1>{% block title %}Editing {{ objs_count }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
|
|
8
8
|
{% if form.errors %}
|
|
9
9
|
<div class="panel panel-danger">
|
|
10
10
|
<div class="panel-heading"><strong>Errors</strong></div>
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
{{ field }}
|
|
28
28
|
{% endfor %}
|
|
29
29
|
<div class="row">
|
|
30
|
+
{% if table %}
|
|
30
31
|
<div class="col-md-8">
|
|
31
32
|
<div class="panel panel-default">
|
|
32
33
|
<div class="table-responsive">
|
|
@@ -34,7 +35,8 @@
|
|
|
34
35
|
</div>
|
|
35
36
|
</div>
|
|
36
37
|
</div>
|
|
37
|
-
|
|
38
|
+
{% endif %}
|
|
39
|
+
<div class="{% if table %} col-md-4 {% else %} col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 {% endif %}">
|
|
38
40
|
<div class="panel panel-default">
|
|
39
41
|
<div class="panel-heading"><strong>{% block form_title %}Attributes{% endblock %}</strong></div>
|
|
40
42
|
<div class="panel-body">
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
</div>
|
|
52
52
|
<div class="row">
|
|
53
|
-
<div class="col-md-
|
|
53
|
+
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1 text-right">
|
|
54
54
|
{% block buttons %}
|
|
55
55
|
{% if editing %}
|
|
56
56
|
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
nautobot/core/testing/api.py
CHANGED
|
@@ -699,13 +699,15 @@ class APIViewTestCases:
|
|
|
699
699
|
serializer_class = get_serializer_for_model(self.model)
|
|
700
700
|
old_serializer = serializer_class(instance, context={"request": None})
|
|
701
701
|
old_data = old_serializer.data
|
|
702
|
+
# save the pk because .delete() will clear it, making the test below always pass
|
|
703
|
+
orig_pk = instance.pk
|
|
702
704
|
instance.delete()
|
|
703
705
|
|
|
704
706
|
response = self.client.post(self._get_list_url(), csv_data, content_type="text/csv", **self.header)
|
|
705
707
|
self.assertHttpStatus(response, status.HTTP_201_CREATED, csv_data)
|
|
706
708
|
# Note that create via CSV is always treated as a bulk-create, and so the response is always a list of dicts
|
|
707
709
|
new_instance = self._get_queryset().get(pk=response.data[0]["id"])
|
|
708
|
-
self.assertNotEqual(new_instance.pk,
|
|
710
|
+
self.assertNotEqual(new_instance.pk, orig_pk)
|
|
709
711
|
|
|
710
712
|
new_serializer = serializer_class(new_instance, context={"request": None})
|
|
711
713
|
new_data = new_serializer.data
|
|
@@ -5,6 +5,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
|
|
5
5
|
from django.test import override_settings, tag
|
|
6
6
|
from django.urls import reverse
|
|
7
7
|
from django.utils.functional import classproperty
|
|
8
|
+
from selenium.webdriver.common.keys import Keys
|
|
8
9
|
from splinter.browser import Browser
|
|
9
10
|
|
|
10
11
|
from nautobot.core import testing
|
|
@@ -74,3 +75,66 @@ class SeleniumTestCase(StaticLiveServerTestCase, testing.NautobotTestCaseMixin):
|
|
|
74
75
|
|
|
75
76
|
def logout(self):
|
|
76
77
|
self.browser.visit(f"{self.live_server_url}/logout")
|
|
78
|
+
|
|
79
|
+
def click_navbar_entry(self, parent_menu_name, child_menu_name):
|
|
80
|
+
"""
|
|
81
|
+
Helper function to click on a parent menu and child menu in the navigation bar.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
parent_menu_xpath = f"//*[@id='navbar']//a[@class='dropdown-toggle' and normalize-space()='{parent_menu_name}']"
|
|
85
|
+
parent_menu = self.browser.find_by_xpath(parent_menu_xpath, wait_time=5)
|
|
86
|
+
if not parent_menu["aria-expanded"] == "true":
|
|
87
|
+
parent_menu.click()
|
|
88
|
+
child_menu_xpath = f"{parent_menu_xpath}/following-sibling::ul//li[.//a[normalize-space()='{child_menu_name}']]"
|
|
89
|
+
child_menu = self.browser.find_by_xpath(child_menu_xpath, wait_time=5)
|
|
90
|
+
child_menu.click()
|
|
91
|
+
|
|
92
|
+
# Wait for body element to appear
|
|
93
|
+
self.assertTrue(self.browser.is_element_present_by_tag("body", wait_time=5), "Page failed to load")
|
|
94
|
+
|
|
95
|
+
def click_list_view_add_button(self):
|
|
96
|
+
"""
|
|
97
|
+
Helper function to click the "Add" button on a list view.
|
|
98
|
+
"""
|
|
99
|
+
add_button = self.browser.find_by_xpath("//a[@id='add-button']", wait_time=5)
|
|
100
|
+
add_button.click()
|
|
101
|
+
|
|
102
|
+
# Wait for body element to appear
|
|
103
|
+
self.assertTrue(self.browser.is_element_present_by_tag("body", wait_time=5), "Page failed to load")
|
|
104
|
+
|
|
105
|
+
def click_edit_form_create_button(self):
|
|
106
|
+
"""
|
|
107
|
+
Helper function to click the "Create" button on a form.
|
|
108
|
+
"""
|
|
109
|
+
add_button = self.browser.find_by_xpath("//button[@name='_create']", wait_time=5)
|
|
110
|
+
add_button.click()
|
|
111
|
+
|
|
112
|
+
# Wait for body element to appear
|
|
113
|
+
self.assertTrue(self.browser.is_element_present_by_tag("body", wait_time=5), "Page failed to load")
|
|
114
|
+
|
|
115
|
+
def fill_select2_field(self, field_name, value):
|
|
116
|
+
"""
|
|
117
|
+
Helper function to fill a Select2 single selection field.
|
|
118
|
+
"""
|
|
119
|
+
self.browser.find_by_xpath(f"//select[@id='id_{field_name}']//following-sibling::span").click()
|
|
120
|
+
search_box = self.browser.find_by_xpath(
|
|
121
|
+
"//*[@class='select2-search select2-search--dropdown']//input", wait_time=5
|
|
122
|
+
)
|
|
123
|
+
for _ in search_box.first.type(value, slowly=True):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# wait for "searching" to disappear
|
|
127
|
+
self.browser.is_element_not_present_by_css(".loading-results", wait_time=5)
|
|
128
|
+
search_box.first.type(Keys.ENTER)
|
|
129
|
+
|
|
130
|
+
def fill_select2_multiselect_field(self, field_name, value):
|
|
131
|
+
"""
|
|
132
|
+
Helper function to fill a Select2 multi-selection field.
|
|
133
|
+
"""
|
|
134
|
+
search_box = self.browser.find_by_xpath(f"//select[@id='id_{field_name}']//following-sibling::span//input")
|
|
135
|
+
for _ in search_box.first.type(value, slowly=True):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# wait for "searching" to disappear
|
|
139
|
+
self.browser.is_element_not_present_by_css(".loading-results", wait_time=5)
|
|
140
|
+
search_box.first.type(Keys.ENTER)
|
nautobot/core/testing/views.py
CHANGED
|
@@ -873,6 +873,8 @@ class ViewTestCases:
|
|
|
873
873
|
response_body,
|
|
874
874
|
)
|
|
875
875
|
|
|
876
|
+
return response
|
|
877
|
+
|
|
876
878
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
877
879
|
def test_list_objects_with_constrained_permission(self):
|
|
878
880
|
instance1, instance2 = self._get_queryset().all()[:2]
|
|
@@ -1112,13 +1114,10 @@ class ViewTestCases:
|
|
|
1112
1114
|
|
|
1113
1115
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1114
1116
|
def test_bulk_edit_form_contains_all_pks(self):
|
|
1115
|
-
# We are testing the intermediary step of bulk_edit
|
|
1117
|
+
# We are testing the intermediary step of all bulk_edit.
|
|
1116
1118
|
# i.e. "_all" passed in the form.
|
|
1117
1119
|
pk_list = self._get_queryset().values_list("pk", flat=True)
|
|
1118
|
-
# We only pass in one pk to test the functionality of "_all"
|
|
1119
|
-
# which should grab all instance pks regardless of "pk"
|
|
1120
1120
|
selected_data = {
|
|
1121
|
-
"pk": pk_list[:1],
|
|
1122
1121
|
"_all": "on",
|
|
1123
1122
|
}
|
|
1124
1123
|
# Assign model-level permission
|
|
@@ -1133,13 +1132,19 @@ class ViewTestCases:
|
|
|
1133
1132
|
# after pressing Edit Selected button.
|
|
1134
1133
|
self.assertHttpStatus(response, 200)
|
|
1135
1134
|
response_body = utils.extract_page_body(response.content.decode(response.charset))
|
|
1135
|
+
# Assert the table which shows all the selected objects is not part of the html body in edit all case
|
|
1136
|
+
self.assertNotIn('<table class="table table-hover table-headings">', response_body)
|
|
1136
1137
|
# Check if all the pks are passed into the BulkEditForm/BulkUpdateForm
|
|
1137
1138
|
for pk in pk_list:
|
|
1138
|
-
self.
|
|
1139
|
+
self.assertNotIn(str(pk), response_body)
|
|
1140
|
+
self.assertInHTML(
|
|
1141
|
+
'<input type="hidden" name="_all" value="True" class="form-control" required="required" placeholder="None" id="id__all">',
|
|
1142
|
+
response_body,
|
|
1143
|
+
)
|
|
1139
1144
|
|
|
1140
1145
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1141
1146
|
def test_bulk_edit_form_contains_all_filtered(self):
|
|
1142
|
-
# We are testing the intermediary step of
|
|
1147
|
+
# We are testing the intermediary step of bulk editing all filtered objects.
|
|
1143
1148
|
# i.e. "_all" passed in the form and filter using query params.
|
|
1144
1149
|
self.add_permissions(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}")
|
|
1145
1150
|
|
|
@@ -1155,7 +1160,6 @@ class ViewTestCases:
|
|
|
1155
1160
|
|
|
1156
1161
|
# Open bulk update form with first two objects
|
|
1157
1162
|
selected_data = {
|
|
1158
|
-
"pk": third_pk, # This is ignored when filtering with "_all"
|
|
1159
1163
|
"_all": "on",
|
|
1160
1164
|
**post_data,
|
|
1161
1165
|
}
|
|
@@ -1164,12 +1168,15 @@ class ViewTestCases:
|
|
|
1164
1168
|
# Expect a 200 status cause we are only rendering the bulk edit table after pressing Edit Selected button.
|
|
1165
1169
|
self.assertHttpStatus(response, 200)
|
|
1166
1170
|
response_body = utils.extract_page_body(response.content.decode(response.charset))
|
|
1167
|
-
# Check if
|
|
1168
|
-
self.
|
|
1169
|
-
self.
|
|
1171
|
+
# Check if all pks is not part of the html.
|
|
1172
|
+
self.assertNotIn(str(first_pk), response_body)
|
|
1173
|
+
self.assertNotIn(str(second_pk), response_body)
|
|
1174
|
+
self.assertNotIn(str(third_pk), response_body)
|
|
1170
1175
|
self.assertIn("Editing 2 ", response_body)
|
|
1171
|
-
|
|
1172
|
-
|
|
1176
|
+
self.assertInHTML(
|
|
1177
|
+
'<input type="hidden" name="_all" value="True" class="form-control" required="required" placeholder="None" id="id__all">',
|
|
1178
|
+
response_body,
|
|
1179
|
+
)
|
|
1173
1180
|
|
|
1174
1181
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1175
1182
|
def test_bulk_edit_objects_with_constrained_permission(self):
|
|
@@ -1271,14 +1278,10 @@ class ViewTestCases:
|
|
|
1271
1278
|
self.assertEqual(self._get_queryset().count(), initial_count - len(pk_list))
|
|
1272
1279
|
|
|
1273
1280
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
1274
|
-
def
|
|
1275
|
-
# We are testing the intermediary step of bulk_delete
|
|
1281
|
+
def test_bulk_delete_form_contains_all_objects(self):
|
|
1282
|
+
# We are testing the intermediary step of bulk_delete all objects.
|
|
1276
1283
|
# i.e. "_all" passed in the form.
|
|
1277
|
-
pk_list = self._get_queryset().values_list("pk", flat=True)
|
|
1278
|
-
# We only pass in one pk to test the functionality of "_all"
|
|
1279
|
-
# which should grab all instance pks regardless of "pks".
|
|
1280
1284
|
selected_data = {
|
|
1281
|
-
"pk": pk_list[:1],
|
|
1282
1285
|
"confirm": True,
|
|
1283
1286
|
"_all": "on",
|
|
1284
1287
|
}
|
|
@@ -1293,13 +1296,16 @@ class ViewTestCases:
|
|
|
1293
1296
|
response = self.client.post(self._get_url("bulk_delete"), selected_data)
|
|
1294
1297
|
self.assertHttpStatus(response, 200)
|
|
1295
1298
|
response_body = utils.extract_page_body(response.content.decode(response.charset))
|
|
1296
|
-
#
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
+
# Assert the table which shows all the selected objects is not part of the html body in delete all case
|
|
1300
|
+
self.assertNotIn('<table class="table table-hover table-headings">', response_body)
|
|
1301
|
+
# Assert none of the hidden input fields for each of the pks that would be deleted is part of the html body
|
|
1302
|
+
for pk in self._get_queryset().values_list("pk", flat=True):
|
|
1303
|
+
self.assertNotIn(str(pk), response_body)
|
|
1304
|
+
self.assertInHTML('<input type="hidden" name="_all" value="true" />', response_body)
|
|
1299
1305
|
|
|
1300
1306
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
1301
1307
|
def test_bulk_delete_form_contains_all_filtered(self):
|
|
1302
|
-
# We are testing the intermediary step of bulk_delete with
|
|
1308
|
+
# We are testing the intermediary step of bulk_delete all with additional filter.
|
|
1303
1309
|
# i.e. "_all" passed in the form and filter using query params.
|
|
1304
1310
|
self.add_permissions(f"{self.model._meta.app_label}.delete_{self.model._meta.model_name}")
|
|
1305
1311
|
|
|
@@ -1321,12 +1327,12 @@ class ViewTestCases:
|
|
|
1321
1327
|
# Expect a 200 status cause we are only rendering the bulk delete table after pressing Delete Selected button.
|
|
1322
1328
|
self.assertHttpStatus(response, 200)
|
|
1323
1329
|
response_body = utils.extract_page_body(response.content.decode(response.charset))
|
|
1324
|
-
# Check if
|
|
1325
|
-
self.
|
|
1326
|
-
self.
|
|
1330
|
+
# Check if all pks is not part of the html.
|
|
1331
|
+
self.assertNotIn(str(first_pk), response_body)
|
|
1332
|
+
self.assertNotIn(str(second_pk), response_body)
|
|
1333
|
+
self.assertNotIn(str(third_pk), response_body)
|
|
1327
1334
|
self.assertIn("<strong>Warning:</strong> The following operation will delete 2 ", response_body)
|
|
1328
|
-
|
|
1329
|
-
self.assertNotIn(f'<input type="hidden" name="pk" value="{third_pk}"', response_body)
|
|
1335
|
+
self.assertInHTML('<input type="hidden" name="_all" value="true" />', response_body)
|
|
1330
1336
|
|
|
1331
1337
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
|
1332
1338
|
def test_bulk_delete_objects_with_constrained_permission(self):
|
|
@@ -61,7 +61,7 @@ class AppNavBarTestCase(SeleniumTestCase):
|
|
|
61
61
|
tab_xpath = "//*[@id='navbar']//span[normalize-space()='Example Menu']/.."
|
|
62
62
|
tab = self.browser.find_by_xpath(tab_xpath)
|
|
63
63
|
tab.click()
|
|
64
|
-
self.
|
|
64
|
+
self.assertEqual(tab["aria-expanded"], "true")
|
|
65
65
|
|
|
66
66
|
group = tab.find_by_xpath(f"{tab_xpath}/following-sibling::ul//li[normalize-space()='Example Group 1']")
|
|
67
67
|
|
|
@@ -82,7 +82,7 @@ class AppNavBarTestCase(SeleniumTestCase):
|
|
|
82
82
|
tab_xpath = "//*[@id='navbar']//*[normalize-space()='Circuits']"
|
|
83
83
|
tab = self.browser.find_by_xpath(tab_xpath)
|
|
84
84
|
tab.click()
|
|
85
|
-
self.
|
|
85
|
+
self.assertEqual(tab["aria-expanded"], "true")
|
|
86
86
|
|
|
87
87
|
for group_name, items in self.navbar["Circuits"].items():
|
|
88
88
|
group = tab.find_by_xpath(f"{tab_xpath}/following-sibling::ul//li[normalize-space()='{group_name}']")
|
|
@@ -114,7 +114,7 @@ class AppNavBarTestCase(SeleniumTestCase):
|
|
|
114
114
|
tab_xpath = "//*[@id='navbar']//*[normalize-space()='Apps']"
|
|
115
115
|
tab = self.browser.find_by_xpath(tab_xpath)
|
|
116
116
|
tab.click()
|
|
117
|
-
self.
|
|
117
|
+
self.assertEqual(tab["aria-expanded"], "true")
|
|
118
118
|
|
|
119
119
|
for group_name, items in self.navbar["Apps"].items():
|
|
120
120
|
group = tab.find_by_xpath(f"{tab_xpath}/following-sibling::ul//li[normalize-space()='{group_name}']")
|
|
@@ -60,7 +60,7 @@ class NavBarTestCase(SeleniumTestCase):
|
|
|
60
60
|
tab_xpath = f"//*[@id='navbar']//span[normalize-space()='{tab_name}']/.."
|
|
61
61
|
tab = self.browser.find_by_xpath(tab_xpath)
|
|
62
62
|
tab.click()
|
|
63
|
-
self.
|
|
63
|
+
self.assertEqual(tab["aria-expanded"], "true")
|
|
64
64
|
|
|
65
65
|
for group_name, items in groups.items():
|
|
66
66
|
# Append onto tab xpath with group name search
|
nautobot/core/tests/test_csv.py
CHANGED
|
@@ -267,6 +267,9 @@ class CSVParsingRelatedTestCase(TestCase):
|
|
|
267
267
|
url = reverse("dcim:device_import")
|
|
268
268
|
response = self.client.post(url, data)
|
|
269
269
|
self.assertEqual(response.status_code, 200)
|
|
270
|
+
# uploading the CSV always returns a 200 code with a page with an error message on it
|
|
271
|
+
# ensure we don't have that error message
|
|
272
|
+
self.assertNotIn("FORM-ERROR", response.content.decode(response.charset))
|
|
270
273
|
self.assertEqual(Device.objects.count(), 4)
|
|
271
274
|
|
|
272
275
|
# Assert TestDevice3 got created with the right fields
|