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.

Files changed (54) hide show
  1. nautobot/apps/utils.py +2 -0
  2. nautobot/cloud/tables.py +1 -0
  3. nautobot/core/forms/forms.py +5 -1
  4. nautobot/core/tables.py +88 -22
  5. nautobot/core/templates/generic/object_bulk_destroy.html +12 -3
  6. nautobot/core/templates/generic/object_bulk_update.html +4 -2
  7. nautobot/core/templates/generic/object_create.html +1 -1
  8. nautobot/core/templates/rest_framework/api.html +3 -0
  9. nautobot/core/testing/api.py +3 -1
  10. nautobot/core/testing/integration.py +64 -0
  11. nautobot/core/testing/views.py +33 -27
  12. nautobot/core/tests/integration/test_app_navbar.py +3 -3
  13. nautobot/core/tests/integration/test_navbar.py +1 -1
  14. nautobot/core/tests/test_csv.py +3 -0
  15. nautobot/core/tests/test_utils.py +25 -5
  16. nautobot/core/utils/lookup.py +35 -0
  17. nautobot/core/views/generic.py +50 -39
  18. nautobot/core/views/mixins.py +97 -43
  19. nautobot/core/views/renderers.py +8 -5
  20. nautobot/dcim/tables/devices.py +3 -0
  21. nautobot/dcim/templates/dcim/device_component_add.html +8 -8
  22. nautobot/dcim/templates/dcim/virtualchassis_add_member.html +2 -2
  23. nautobot/dcim/templates/dcim/virtualchassis_edit.html +2 -2
  24. nautobot/dcim/tests/integration/test_create_device.py +86 -0
  25. nautobot/extras/tests/test_relationships.py +1 -0
  26. nautobot/extras/views.py +1 -0
  27. nautobot/ipam/factory.py +3 -0
  28. nautobot/ipam/filters.py +5 -0
  29. nautobot/ipam/forms.py +17 -0
  30. nautobot/ipam/models.py +2 -1
  31. nautobot/ipam/signals.py +2 -2
  32. nautobot/ipam/tables.py +3 -3
  33. nautobot/ipam/templates/ipam/ipaddress_assign.html +2 -2
  34. nautobot/ipam/tests/test_models.py +113 -1
  35. nautobot/ipam/tests/test_views.py +39 -5
  36. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +131 -6
  37. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +175 -0
  38. nautobot/project-static/docs/code-reference/nautobot/apps/utils.html +94 -0
  39. nautobot/project-static/docs/code-reference/nautobot/apps/views.html +4 -4
  40. nautobot/project-static/docs/objects.inv +0 -0
  41. nautobot/project-static/docs/release-notes/version-2.3.html +293 -138
  42. nautobot/project-static/docs/search/search_index.json +1 -1
  43. nautobot/project-static/docs/sitemap.xml +270 -270
  44. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  45. nautobot/project-static/docs/user-guide/administration/guides/request-profiling.html +39 -0
  46. nautobot/virtualization/forms.py +24 -0
  47. nautobot/virtualization/templates/virtualization/vminterface_edit.html +1 -0
  48. nautobot/virtualization/tests/test_views.py +7 -2
  49. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/METADATA +1 -1
  50. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/RECORD +54 -53
  51. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/LICENSE.txt +0 -0
  52. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/NOTICE +0 -0
  53. {nautobot-2.3.10.dist-info → nautobot-2.3.11.dist-info}/WHEEL +0 -0
  54. {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
  )
@@ -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 lookup
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 = lookup.get_model_for_view_name(column.column.viewname)
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 = lookup.get_route_for_model(model, "changelog")
399
- edit_route = lookup.get_route_for_model(model, "edit")
400
- delete_route = lookup.get_route_for_model(model, "delete")
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
- :param viewname: The view name to use for URL resolution
468
- :param view_kwargs: Additional kwargs to pass for URL resolution (optional)
469
- :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional)
470
- :param reverse_lookup: The reverse lookup parameter to use to derive the count. If not specified, the first key
471
- in `url_params` will be implicitly used as the `reverse_lookup` value.
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__(self, viewname, *args, view_kwargs=None, url_params=None, reverse_lookup=None, default=0, **kwargs):
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
- if value:
483
- url = reverse(self.viewname, kwargs=self.view_kwargs)
484
- if self.url_params:
485
- url += "?" + "&".join([f"{k}={getattr(record, v)}" for k, v in self.url_params.items()])
486
- return format_html('<a href="{}">{}</a>', url, value)
487
- return value
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 {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
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 {{ table.rows|length }} {{ obj_type_plural }}. Please carefully review the {{ obj_type_plural }} to be deleted and confirm below.</p>
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 {{ table.rows|length }} {{ obj_type_plural }}</button>
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 {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
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
- <div class="col-md-4">
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-6 col-md-offset-3 text-right">
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>
@@ -20,6 +20,9 @@
20
20
  #main-content {
21
21
  min-height: calc(100vh - 70px);
22
22
  }
23
+ .request-info {
24
+ clear: both; /* Fix description floating issue */
25
+ }
23
26
  </style>
24
27
  {% endblock body %}
25
28
 
@@ -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, 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)
@@ -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 with pagination applied.
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.assertIn(f'<input type="hidden" name="pk" value="{pk}"', response_body)
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 bulk_edit with pagination applied and additional filter.
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 the first and second pk is passed into the form.
1168
- self.assertIn(f'<input type="hidden" name="pk" value="{first_pk}"', response_body)
1169
- self.assertIn(f'<input type="hidden" name="pk" value="{second_pk}"', response_body)
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
- # Check if the third pk is not passed into the form.
1172
- self.assertNotIn(f'<input type="hidden" name="pk" value="{third_pk}"', response_body)
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 test_bulk_delete_form_contains_all_pks(self):
1275
- # We are testing the intermediary step of bulk_delete with pagination applied.
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
- # Check if all the pks are passed into the BulkDeleteForm/BulkDestroyForm
1297
- for pk in pk_list:
1298
- self.assertIn(f'<input type="hidden" name="pk" value="{pk}"', response_body)
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 pagination applied and additional filter.
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 the first and second pk is passed into the form.
1325
- self.assertIn(f'<input type="hidden" name="pk" value="{first_pk}"', response_body)
1326
- self.assertIn(f'<input type="hidden" name="pk" value="{second_pk}"', response_body)
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
- # Check if the third pk is not passed into the form.
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.assertTrue(bool(tab["aria-expanded"]))
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.assertTrue(bool(tab["aria-expanded"]))
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.assertTrue(bool(tab["aria-expanded"]))
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.assertTrue(bool(tab["aria-expanded"]))
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
@@ -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