nautobot 2.4.4__py3-none-any.whl → 2.4.6__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.
Files changed (139) hide show
  1. nautobot/__init__.py +19 -3
  2. nautobot/core/api/mixins.py +10 -0
  3. nautobot/core/celery/__init__.py +5 -3
  4. nautobot/core/celery/encoders.py +2 -2
  5. nautobot/core/forms/fields.py +21 -5
  6. nautobot/core/forms/utils.py +1 -0
  7. nautobot/core/jobs/__init__.py +3 -2
  8. nautobot/core/jobs/bulk_actions.py +1 -1
  9. nautobot/core/management/commands/generate_test_data.py +1 -1
  10. nautobot/core/models/name_color_content_types.py +9 -0
  11. nautobot/core/models/validators.py +7 -0
  12. nautobot/core/settings.py +0 -14
  13. nautobot/core/settings.yaml +0 -28
  14. nautobot/core/tables.py +6 -1
  15. nautobot/core/templates/generic/object_retrieve.html +1 -1
  16. nautobot/core/testing/__init__.py +2 -0
  17. nautobot/core/testing/api.py +18 -0
  18. nautobot/core/testing/mixins.py +9 -0
  19. nautobot/core/tests/nautobot_config.py +0 -2
  20. nautobot/core/tests/runner.py +17 -140
  21. nautobot/core/tests/test_api.py +4 -4
  22. nautobot/core/tests/test_authentication.py +83 -4
  23. nautobot/core/tests/test_forms.py +11 -8
  24. nautobot/core/tests/test_graphql.py +9 -0
  25. nautobot/core/tests/test_jobs.py +33 -27
  26. nautobot/core/ui/object_detail.py +31 -0
  27. nautobot/dcim/factory.py +2 -0
  28. nautobot/dcim/filters/__init__.py +5 -0
  29. nautobot/dcim/forms.py +17 -1
  30. nautobot/dcim/migrations/0068_alter_softwareimagefile_download_url.py +19 -0
  31. nautobot/dcim/migrations/0069_softwareimagefile_external_integration.py +25 -0
  32. nautobot/dcim/models/devices.py +9 -2
  33. nautobot/dcim/tables/devices.py +1 -0
  34. nautobot/dcim/templates/dcim/softwareimagefile_retrieve.html +4 -0
  35. nautobot/dcim/tests/test_api.py +74 -31
  36. nautobot/dcim/tests/test_filters.py +2 -0
  37. nautobot/dcim/tests/test_jobs.py +4 -6
  38. nautobot/dcim/tests/test_models.py +65 -0
  39. nautobot/dcim/tests/test_views.py +3 -0
  40. nautobot/extras/choices.py +8 -3
  41. nautobot/extras/forms/forms.py +7 -3
  42. nautobot/extras/jobs.py +181 -103
  43. nautobot/extras/management/utils.py +13 -2
  44. nautobot/extras/models/datasources.py +4 -1
  45. nautobot/extras/models/jobs.py +20 -17
  46. nautobot/extras/plugins/marketplace_manifest.yml +18 -0
  47. nautobot/extras/tables.py +29 -34
  48. nautobot/extras/templates/extras/inc/panel_changelog.html +1 -1
  49. nautobot/extras/templates/extras/inc/panel_jobhistory.html +1 -1
  50. nautobot/extras/templates/extras/status.html +1 -37
  51. nautobot/extras/test_jobs/atomic_transaction.py +6 -6
  52. nautobot/extras/test_jobs/fail.py +75 -1
  53. nautobot/extras/tests/integration/test_notes.py +1 -1
  54. nautobot/extras/tests/test_api.py +23 -8
  55. nautobot/extras/tests/test_changelog.py +4 -4
  56. nautobot/extras/tests/test_customfields.py +3 -0
  57. nautobot/extras/tests/test_datasources.py +64 -54
  58. nautobot/extras/tests/test_jobs.py +69 -62
  59. nautobot/extras/tests/test_models.py +1 -1
  60. nautobot/extras/tests/test_plugins.py +19 -13
  61. nautobot/extras/tests/test_relationships.py +14 -5
  62. nautobot/extras/tests/test_tags.py +2 -2
  63. nautobot/extras/tests/test_views.py +15 -6
  64. nautobot/extras/urls.py +1 -30
  65. nautobot/extras/views.py +17 -55
  66. nautobot/ipam/forms.py +15 -0
  67. nautobot/ipam/querysets.py +6 -0
  68. nautobot/ipam/tables.py +6 -2
  69. nautobot/ipam/templates/ipam/namespace_retrieve.html +0 -41
  70. nautobot/ipam/templates/ipam/rir.html +1 -43
  71. nautobot/ipam/templates/ipam/service.html +2 -46
  72. nautobot/ipam/templates/ipam/service_edit.html +1 -17
  73. nautobot/ipam/templates/ipam/service_retrieve.html +7 -0
  74. nautobot/ipam/tests/migration/__init__.py +0 -0
  75. nautobot/ipam/tests/migration/test_migrations.py +510 -0
  76. nautobot/ipam/tests/test_api.py +66 -36
  77. nautobot/ipam/tests/test_filters.py +0 -10
  78. nautobot/ipam/tests/test_models.py +16 -0
  79. nautobot/ipam/tests/test_views.py +44 -2
  80. nautobot/ipam/urls.py +2 -67
  81. nautobot/ipam/utils/migrations.py +185 -152
  82. nautobot/ipam/utils/testing.py +177 -0
  83. nautobot/ipam/views.py +119 -198
  84. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +43 -5
  85. nautobot/project-static/docs/code-reference/nautobot/apps/models.html +47 -0
  86. nautobot/project-static/docs/code-reference/nautobot/apps/tables.html +18 -0
  87. nautobot/project-static/docs/code-reference/nautobot/apps/testing.html +35 -0
  88. nautobot/project-static/docs/code-reference/nautobot/apps/ui.html +63 -0
  89. nautobot/project-static/docs/development/apps/api/testing.html +0 -87
  90. nautobot/project-static/docs/development/apps/migration/dependency-updates.html +1 -1
  91. nautobot/project-static/docs/development/core/best-practices.html +3 -3
  92. nautobot/project-static/docs/development/core/getting-started.html +78 -107
  93. nautobot/project-static/docs/development/core/release-checklist.html +1 -1
  94. nautobot/project-static/docs/development/core/style-guide.html +1 -1
  95. nautobot/project-static/docs/development/core/testing.html +24 -198
  96. nautobot/project-static/docs/development/jobs/index.html +27 -14
  97. nautobot/project-static/docs/media/user-guide/administration/getting-started/nautobot-cloud.png +0 -0
  98. nautobot/project-static/docs/objects.inv +0 -0
  99. nautobot/project-static/docs/overview/application_stack.html +1 -1
  100. nautobot/project-static/docs/release-notes/version-2.4.html +409 -1
  101. nautobot/project-static/docs/requirements.txt +1 -1
  102. nautobot/project-static/docs/search/search_index.json +1 -1
  103. nautobot/project-static/docs/sitemap.xml +290 -290
  104. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  105. nautobot/project-static/docs/user-guide/administration/configuration/settings.html +2 -48
  106. nautobot/project-static/docs/user-guide/administration/guides/permissions.html +71 -0
  107. nautobot/project-static/docs/user-guide/administration/installation/http-server.html +3 -1
  108. nautobot/project-static/docs/user-guide/administration/installation/index.html +257 -16
  109. nautobot/project-static/docs/user-guide/administration/tools/nautobot-server.html +1 -1
  110. nautobot/project-static/docs/user-guide/administration/upgrading/upgrading.html +2 -2
  111. nautobot/project-static/docs/user-guide/core-data-model/dcim/softwareimagefile.html +4 -0
  112. nautobot/project-static/docs/user-guide/feature-guides/contacts-and-teams.html +11 -11
  113. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-devices.html +8 -8
  114. nautobot/project-static/docs/user-guide/feature-guides/getting-started/creating-location-types-and-locations.html +1 -0
  115. nautobot/project-static/docs/user-guide/feature-guides/getting-started/interfaces.html +40 -25
  116. nautobot/project-static/docs/user-guide/feature-guides/getting-started/ipam.html +4 -4
  117. nautobot/project-static/docs/user-guide/feature-guides/getting-started/platforms.html +1 -1
  118. nautobot/project-static/docs/user-guide/feature-guides/getting-started/search-bar.html +77 -5
  119. nautobot/project-static/docs/user-guide/feature-guides/getting-started/tenants.html +1 -1
  120. nautobot/project-static/docs/user-guide/feature-guides/getting-started/vlans-and-vlan-groups.html +0 -1
  121. nautobot/project-static/docs/user-guide/feature-guides/git-data-source.html +1 -1
  122. nautobot/project-static/docs/user-guide/index.html +89 -2
  123. nautobot/project-static/docs/user-guide/platform-functionality/webhook.html +207 -122
  124. nautobot/virtualization/forms.py +20 -0
  125. nautobot/virtualization/templates/virtualization/clustergroup.html +1 -39
  126. nautobot/virtualization/templates/virtualization/clustertype.html +1 -0
  127. nautobot/virtualization/tests/test_api.py +14 -3
  128. nautobot/virtualization/tests/test_views.py +10 -2
  129. nautobot/virtualization/urls.py +10 -93
  130. nautobot/virtualization/views.py +33 -72
  131. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/METADATA +8 -7
  132. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/RECORD +137 -132
  133. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/WHEEL +1 -1
  134. nautobot/core/tests/performance_baselines.yml +0 -8900
  135. nautobot/ipam/tests/test_migrations.py +0 -462
  136. /nautobot/ipam/templates/ipam/{namespace_ipaddresses.html → namespace_ip_addresses.html} +0 -0
  137. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/LICENSE.txt +0 -0
  138. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/NOTICE +0 -0
  139. {nautobot-2.4.4.dist-info → nautobot-2.4.6.dist-info}/entry_points.txt +0 -0
nautobot/__init__.py CHANGED
@@ -14,18 +14,34 @@ __initialized = False
14
14
 
15
15
  def add_success_logger():
16
16
  """Add a custom log level for success messages."""
17
- SUCCESS = 25
17
+ SUCCESS = 25 # between INFO and WARNING
18
18
  logging.addLevelName(SUCCESS, "SUCCESS")
19
19
 
20
- def success(self, message, *args, **kws):
20
+ def success(self, message, *args, **kwargs):
21
+ kwargs["stacklevel"] = kwargs.get("stacklevel", 1) + 1 # so that funcName is the caller function, not "success"
21
22
  if self.isEnabledFor(SUCCESS):
22
- self._log(SUCCESS, message, args, **kws)
23
+ self._log(SUCCESS, message, args, **kwargs)
23
24
 
24
25
  logging.Logger.success = success
25
26
  return success
26
27
 
27
28
 
29
+ def add_failure_logger():
30
+ """Add a custom log level for failure messages less severe than an ERROR."""
31
+ FAILURE = 35 # between WARNING and ERROR
32
+ logging.addLevelName(FAILURE, "FAILURE")
33
+
34
+ def failure(self, message, *args, **kwargs):
35
+ kwargs["stacklevel"] = kwargs.get("stacklevel", 1) + 1 # so that funcName is the caller function, not "failure"
36
+ if self.isEnabledFor(FAILURE):
37
+ self._log(FAILURE, message, args, **kwargs)
38
+
39
+ logging.Logger.failure = failure
40
+ return failure
41
+
42
+
28
43
  add_success_logger()
44
+ add_failure_logger()
29
45
  logger = logging.getLogger(__name__)
30
46
 
31
47
 
@@ -109,6 +109,16 @@ class WritableSerializerMixin:
109
109
  queryset = self.queryset
110
110
  else:
111
111
  queryset = self.Meta.model.objects
112
+
113
+ # Apply user permission on related objects
114
+ if (
115
+ "request" in self.context
116
+ and self.context["request"]
117
+ and self.context["request"].user
118
+ and hasattr(queryset, "restrict")
119
+ ):
120
+ queryset = queryset.restrict(self.context["request"].user, "view")
121
+
112
122
  if isinstance(data, list):
113
123
  return [self.get_object(data=entry, queryset=queryset) for entry in data]
114
124
  return self.get_object(data=data, queryset=queryset)
@@ -14,7 +14,7 @@ from django.utils.module_loading import import_string
14
14
  from kombu.serialization import register
15
15
  from prometheus_client import CollectorRegistry, multiprocess, start_http_server
16
16
 
17
- from nautobot import add_success_logger
17
+ from nautobot import add_failure_logger, add_success_logger
18
18
  from nautobot.core.celery.control import discard_git_repository, refresh_git_repository # noqa: F401 # unused-import
19
19
  from nautobot.core.celery.encoders import NautobotKombuJSONEncoder
20
20
  from nautobot.core.celery.log import NautobotDatabaseHandler
@@ -138,14 +138,16 @@ def add_nautobot_log_handler(logger_instance, log_format=None):
138
138
 
139
139
  @signals.after_setup_logger.connect
140
140
  def setup_nautobot_global_logging(logger, **kwargs): # pylint: disable=redefined-outer-name
141
- """Add SUCCESS log to celery global logger."""
141
+ """Add SUCCESS and FAILURE logs to celery global logger."""
142
142
  logger.success = add_success_logger()
143
+ logger.failure = add_failure_logger()
143
144
 
144
145
 
145
146
  @signals.after_setup_task_logger.connect
146
147
  def setup_nautobot_task_logging(logger, **kwargs): # pylint: disable=redefined-outer-name
147
- """Add SUCCESS log to celery task logger."""
148
+ """Add SUCCESS and FAILURE logs to celery task logger."""
148
149
  logger.success = add_success_logger()
150
+ logger.failure = add_failure_logger()
149
151
 
150
152
 
151
153
  @signals.celeryd_after_setup.connect
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
 
3
+ from django.db import models
3
4
  from rest_framework.utils.encoders import JSONEncoder
4
5
 
5
6
  logger = logging.getLogger(__name__)
@@ -26,10 +27,9 @@ class NautobotKombuJSONEncoder(JSONEncoder):
26
27
  def default(self, obj):
27
28
  # Import here to avoid django.core.exceptions.ImproperlyConfigured Error.
28
29
  # Core App is not set up yet if we import this at the top of the file.
29
- from nautobot.core.models import BaseModel
30
30
  from nautobot.core.models.managers import TagsManager
31
31
 
32
- if isinstance(obj, BaseModel):
32
+ if isinstance(obj, models.Model):
33
33
  cls = obj.__class__
34
34
  module = cls.__module__
35
35
  qual_name = ".".join([module, cls.__qualname__]) # fully qualified dotted import path
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import re
3
4
 
4
5
  from django import forms as django_forms
@@ -11,6 +12,7 @@ from django.db.models import Q
11
12
  from django.forms.fields import BoundField, CallableChoiceIterator, InvalidJSONInput, JSONField as _JSONField
12
13
  from django.templatetags.static import static
13
14
  from django.urls import reverse
15
+ from django.urls.exceptions import NoReverseMatch
14
16
  from django.utils.html import format_html
15
17
  import django_filters
16
18
  from netaddr import EUI
@@ -21,6 +23,8 @@ from nautobot.core.forms import widgets
21
23
  from nautobot.core.models import validators
22
24
  from nautobot.core.utils import data as data_utils, lookup
23
25
 
26
+ logger = logging.getLogger(__name__)
27
+
24
28
  __all__ = (
25
29
  "CSVChoiceField",
26
30
  "CSVContentTypeField",
@@ -591,8 +595,16 @@ class DynamicModelChoiceMixin:
591
595
  widget = bound_field.field.widget
592
596
  if not widget.attrs.get("data-url"):
593
597
  route = lookup.get_route_for_model(self.queryset.model, "list", api=True)
594
- data_url = reverse(route)
595
- widget.attrs["data-url"] = data_url
598
+ try:
599
+ data_url = reverse(route)
600
+ widget.attrs["data-url"] = data_url
601
+ except NoReverseMatch:
602
+ logger.error(
603
+ 'API route lookup "%s" failed for model %s, form field "%s" will not work properly',
604
+ route,
605
+ self.queryset.model.__name__,
606
+ bound_field.name,
607
+ )
596
608
 
597
609
  return bound_field
598
610
 
@@ -800,9 +812,13 @@ class NumericArrayField(SimpleArrayField):
800
812
  def to_python(self, value):
801
813
  try:
802
814
  if not value:
803
- value = ""
804
- else:
805
- value = ",".join([str(n) for n in forms.parse_numeric_range(value)])
815
+ return []
816
+
817
+ if isinstance(value, list):
818
+ value = ",".join([str(n) for n in value])
819
+
820
+ value = ",".join([str(n) for n in forms.parse_numeric_range(value)])
821
+
806
822
  except (TypeError, ValueError) as error:
807
823
  raise ValidationError(error)
808
824
  return super().to_python(value)
@@ -41,6 +41,7 @@ def parse_numeric_range(input_string, base=10):
41
41
  raise TypeError("Input value must be a string using a range format.")
42
42
  except ValueError:
43
43
  begin, end = dash_range, dash_range
44
+
44
45
  begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
45
46
  values.extend(range(begin, end))
46
47
  # Remove duplicates and sort
@@ -295,8 +295,9 @@ class ImportObjects(Job):
295
295
  validation_failed = True
296
296
  else:
297
297
  validation_failed = True
298
- for field, err in serializer.errors.items():
299
- self.logger.error("Row %d: `%s`: `%s`", row, field, err[0])
298
+ for field, errs in serializer.errors.items():
299
+ for err in errs:
300
+ self.logger.error("Row %d: `%s`: `%s`", row, field, err)
300
301
  return new_objs, validation_failed
301
302
 
302
303
  def run(self, *, content_type, csv_data=None, csv_file=None, roll_back_if_error=False): # pylint:disable=arguments-differ
@@ -108,7 +108,7 @@ class BulkEditObjects(Job):
108
108
  if form.cleaned_data[field_name]:
109
109
  getattr(obj, field_name).set(form.cleaned_data[field_name])
110
110
  # Normal fields
111
- elif form.cleaned_data[field_name] not in (None, ""):
111
+ elif form.cleaned_data[field_name] not in (None, "", []):
112
112
  setattr(obj, field_name, form.cleaned_data[field_name])
113
113
 
114
114
  # Update custom fields
@@ -213,6 +213,7 @@ class Command(BaseCommand):
213
213
  _create_batch(PlatformFactory, 20, description="with Manufacturers", has_manufacturer=True)
214
214
  _create_batch(PlatformFactory, 5, description="without Manufacturers", has_manufacturer=False)
215
215
  _create_batch(SoftwareVersionFactory, 20, description="to be usable by Devices")
216
+ _create_batch(ExternalIntegrationFactory, 20)
216
217
  _create_batch(SoftwareImageFileFactory, 25, description="to be usable by DeviceTypes")
217
218
  _create_batch(ManufacturerFactory, 4, description="without Platforms") # 4 more hard-coded Manufacturers
218
219
  _create_batch(DeviceTypeFactory, 30)
@@ -312,7 +313,6 @@ class Command(BaseCommand):
312
313
  has_pp_info=True,
313
314
  has_description=True,
314
315
  )
315
- _create_batch(ExternalIntegrationFactory, 20)
316
316
  _create_batch(ControllerFactory, 10, description="with Devices or DeviceRedundancyGroups")
317
317
  _create_batch(ControllerManagedDeviceGroupFactory, 5, description="without any Devices")
318
318
  _create_batch(SupportedDataRateFactory, 20)
@@ -1,10 +1,12 @@
1
1
  from django.contrib.contenttypes.models import ContentType
2
2
  from django.db import models
3
+ from django.utils.html import format_html
3
4
 
4
5
  from nautobot.core.choices import ColorChoices
5
6
  from nautobot.core.constants import CHARFIELD_MAX_LENGTH
6
7
  from nautobot.core.models import BaseManager, BaseModel, ContentTypeRelatedQuerySet
7
8
  from nautobot.core.models.fields import ColorField
9
+ from nautobot.core.templatetags import helpers
8
10
  from nautobot.extras.models.change_logging import ChangeLoggedModel
9
11
 
10
12
  # Importing CustomFieldModel, ChangeLoggedModel, RelationshipModel from nautobot.extras.models
@@ -54,3 +56,10 @@ class NameColorContentTypesModel(
54
56
 
55
57
  def get_content_types(self):
56
58
  return ",".join(f"{ct.app_label}.{ct.model}" for ct in self.content_types.all())
59
+
60
+ def get_color_display(self):
61
+ if self.color:
62
+ return format_html(
63
+ '<span class="label color-block" style="background-color: #{}">&nbsp;</span>', self.color
64
+ )
65
+ return helpers.placeholder(self.color)
@@ -27,8 +27,15 @@ class EnhancedURLValidator(URLValidator):
27
27
  r"\Z",
28
28
  re.IGNORECASE,
29
29
  )
30
+
30
31
  schemes = settings.ALLOWED_URL_SCHEMES
31
32
 
33
+ def __getattribute__(self, name):
34
+ """Dynamically fetch schemes each time it's accessed."""
35
+ if name == "schemes":
36
+ self.schemes = settings.ALLOWED_URL_SCHEMES # Always return the latest list
37
+ return super().__getattribute__(name)
38
+
32
39
 
33
40
  class ExclusionValidator(BaseValidator):
34
41
  """
nautobot/core/settings.py CHANGED
@@ -287,20 +287,6 @@ TEST_USE_FACTORIES = is_truthy(os.getenv("NAUTOBOT_TEST_USE_FACTORIES", "False")
287
287
  # Pseudo-random number generator seed, for reproducibility of test results.
288
288
  TEST_FACTORY_SEED = os.getenv("NAUTOBOT_TEST_FACTORY_SEED", None)
289
289
 
290
- #
291
- # django-slowtests
292
- #
293
-
294
- # Performance test uses `NautobotPerformanceTestRunner` to run, which is only available once you have `django-slowtests` installed in your dev environment.
295
- # `invoke performance-test` and adding `--performance-report` or `--performance-snapshot` at the end of the `invoke` command
296
- # will automatically opt to NautobotPerformanceTestRunner to run the tests.
297
-
298
- # The baseline file that the performance test is running against
299
- # TODO we need to replace the baselines in this file with more consistent results at least for CI
300
- TEST_PERFORMANCE_BASELINE_FILE = os.getenv(
301
- "NAUTOBOT_TEST_PERFORMANCE_BASELINE_FILE", "nautobot/core/tests/performance_baselines.yml"
302
- )
303
-
304
290
  #
305
291
  # Django Prometheus
306
292
  #
@@ -1926,34 +1926,6 @@ properties:
1926
1926
  version_added: "1.5.0"
1927
1927
  see_also:
1928
1928
  "`TEST_USE_FACTORIES`": "#test_use_factories"
1929
- TEST_PERFORMANCE_BASELINE_FILE:
1930
- default: "nautobot/core/tests/performance_baselines.yml"
1931
- description: "File path of a YAML file providing baseline times for all performance-related tests."
1932
- details: |-
1933
- The YAML file should conform to the following format:
1934
-
1935
- ```yaml
1936
- tests:
1937
- - name: >-
1938
- test_run_job_with_sensitive_variables_and_requires_approval
1939
- (nautobot.extras.tests.test_views.JobTestCase)
1940
- execution_time: 4.799533
1941
- - name: test_run_missing_schedule (nautobot.extras.tests.test_views.JobTestCase)
1942
- execution_time: 4.367563
1943
- - name: test_run_now_missing_args (nautobot.extras.tests.test_views.JobTestCase)
1944
- execution_time: 4.363194
1945
- - name: >-
1946
- test_create_object_with_constrained_permission
1947
- (nautobot.extras.tests.test_views.GraphQLQueriesTestCase)
1948
- execution_time: 3.474244
1949
- - name: >-
1950
- test_run_now_constrained_permissions
1951
- (nautobot.extras.tests.test_views.JobTestCase)
1952
- execution_time: 2.727531
1953
- ```
1954
- environment_variable: "NAUTOBOT_TEST_PERFORMANCE_BASELINE_FILE"
1955
- type: "string"
1956
- version_added: "1.5.0"
1957
1929
  TEST_USE_FACTORIES:
1958
1930
  default: false
1959
1931
  description: >-
nautobot/core/tables.py CHANGED
@@ -493,6 +493,9 @@ class LinkedCountColumn(django_tables2.Column):
493
493
  reverse_lookup (str, optional): The reverse lookup parameter to use to derive the count.
494
494
  If not specified, the first key in `url_params` will be implicitly used as the `reverse_lookup` value.
495
495
  distinct (bool, optional): Parameter passed through to `count_related()`.
496
+ display_field (str, optional): Name of the field to use when displaying an object rather than just a count.
497
+ This will be passed to hyperlinked_object() as the `field` parameter
498
+ If not specified, it will use the "display" field.
496
499
  **kwargs (dict, optional): As the parent Column class.
497
500
 
498
501
  Examples:
@@ -536,6 +539,7 @@ class LinkedCountColumn(django_tables2.Column):
536
539
  reverse_lookup=None,
537
540
  distinct=False,
538
541
  default=None,
542
+ display_field="display",
539
543
  **kwargs,
540
544
  ):
541
545
  self.viewname = viewname
@@ -544,6 +548,7 @@ class LinkedCountColumn(django_tables2.Column):
544
548
  self.url_params = url_params
545
549
  self.reverse_lookup = reverse_lookup or next(iter(url_params.keys()))
546
550
  self.distinct = distinct
551
+ self.display_field = display_field
547
552
  self.model = get_model_for_view_name(self.viewname)
548
553
  super().__init__(*args, default=default, **kwargs)
549
554
 
@@ -565,7 +570,7 @@ class LinkedCountColumn(django_tables2.Column):
565
570
  if value > 1:
566
571
  return format_html('<a href="{}" class="badge">{}</a>', url, value)
567
572
  if related_record is not None:
568
- return helpers.hyperlinked_object(related_record)
573
+ return helpers.hyperlinked_object(related_record, self.display_field)
569
574
  if value == 1:
570
575
  return format_html('<a href="{}" class="badge">{}</a>', url, value)
571
576
  return helpers.placeholder(value)
@@ -117,7 +117,7 @@
117
117
  {% if perms.extras.view_note %}
118
118
  {% if active_tab != 'notes' and object.get_notes_url or active_tab == 'notes' %}
119
119
  <li role="presentation"{% if active_tab == 'notes' %} class="active"{% endif %}>
120
- <a href="{{ object.get_notes_url }}">Notes</a>
120
+ <a href="{{ object.get_notes_url }}">Notes {% badge object.notes.count %}</a>
121
121
  </li>
122
122
  {% endif %}
123
123
  {% endif %}
@@ -68,6 +68,8 @@ def run_job_for_testing(job, username="test-user", profile=False, **kwargs):
68
68
  username=username, defaults={"is_superuser": True, "password": "password"}
69
69
  )
70
70
  # Run the job synchronously in the current thread as if it were being executed by a worker
71
+ # TODO: in Nautobot core testing, we set `CELERY_TASK_ALWAYS_EAGER = True`, so we *could* use enqueue_job() instead,
72
+ # but switching now would be a potentially breaking change for apps...
71
73
  job_result = JobResult.execute_job(
72
74
  job_model=job,
73
75
  user=user_instance,
@@ -702,6 +702,9 @@ class APIViewTestCases:
702
702
  else:
703
703
  self.assertEqual(obj.key, expected_slug)
704
704
 
705
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
706
+ # long term fix should be using appropriate object permissions instead of the blanket override
707
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
705
708
  def test_create_object(self):
706
709
  """
707
710
  POST a single object with permission.
@@ -738,6 +741,9 @@ class APIViewTestCases:
738
741
  self.assertEqual(len(objectchanges), 1)
739
742
  self.assertEqual(objectchanges[0].action, extras_choices.ObjectChangeActionChoices.ACTION_CREATE)
740
743
 
744
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
745
+ # long term fix should be using appropriate object permissions instead of the blanket override
746
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
741
747
  def test_recreate_object_csv(self):
742
748
  """CSV export an object, delete it, and recreate it via CSV import."""
743
749
  if hasattr(self, "get_deletable_object"):
@@ -801,6 +807,9 @@ class APIViewTestCases:
801
807
  f"{field_name} should have been unchanged on delete/recreate but it differs!",
802
808
  )
803
809
 
810
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
811
+ # long term fix should be using appropriate object permissions instead of the blanket override
812
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
804
813
  def test_bulk_create_objects(self):
805
814
  """
806
815
  POST a set of objects in a single request.
@@ -853,6 +862,9 @@ class APIViewTestCases:
853
862
  response = self.client.patch(url, update_data, format="json", **self.header)
854
863
  self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
855
864
 
865
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
866
+ # long term fix should be using appropriate object permissions instead of the blanket override
867
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
856
868
  def test_update_object(self):
857
869
  """
858
870
  PATCH a single object identified by its ID.
@@ -965,6 +977,9 @@ class APIViewTestCases:
965
977
  instance.refresh_from_db()
966
978
  self.assertInstanceEqual(instance, update_data, exclude=self.validation_excluded_fields, api=True)
967
979
 
980
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
981
+ # long term fix should be using appropriate object permissions instead of the blanket override
982
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
968
983
  def test_get_put_round_trip(self):
969
984
  """GET and then PUT an object and verify that it's accepted and unchanged."""
970
985
  self.maxDiff = None
@@ -995,6 +1010,9 @@ class APIViewTestCases:
995
1010
  updated_serialized_object.pop("last_updated", None)
996
1011
  self.assertEqual(initial_serialized_object, updated_serialized_object)
997
1012
 
1013
+ # TODO: The override_settings here is a temporary workaround for not breaking any app tests
1014
+ # long term fix should be using appropriate object permissions instead of the blanket override
1015
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
998
1016
  def test_bulk_update_objects(self):
999
1017
  """
1000
1018
  PATCH a set of objects in a single request.
@@ -18,6 +18,7 @@ from nautobot.core.models import fields as core_fields
18
18
  from nautobot.core.testing import utils
19
19
  from nautobot.core.utils import permissions
20
20
  from nautobot.extras import management, models as extras_models
21
+ from nautobot.extras.choices import JobResultStatusChoices
21
22
  from nautobot.users import models as users_models
22
23
 
23
24
  # Use the proper swappable User model
@@ -188,6 +189,14 @@ class NautobotTestCaseMixin:
188
189
  err_message = f"{msg}\n{err_message}"
189
190
  self.assertIn(response.status_code, expected_status, err_message)
190
191
 
192
+ def assertJobResultStatus(self, job_result, expected_status=JobResultStatusChoices.STATUS_SUCCESS):
193
+ """Assert that the given job_result has the expected_status, or print the job logs to aid in debugging."""
194
+ self.assertEqual(
195
+ job_result.status,
196
+ expected_status,
197
+ (job_result.traceback, list(job_result.job_log_entries.values_list("message", flat=True))),
198
+ )
199
+
191
200
  def assertInstanceEqual(self, instance, data, exclude=None, api=False):
192
201
  """
193
202
  Compare a model instance to a dictionary, checking that its attribute values match those specified
@@ -44,8 +44,6 @@ CONSTANCE_BACKEND = "constance.backends.memory.MemoryBackend"
44
44
  TEST_USE_FACTORIES = True
45
45
  # For now, use a constant PRNG seed for consistent results. In the future we can remove this for fuzzier testing.
46
46
  TEST_FACTORY_SEED = "Nautobot"
47
- # File in which all performance-specifc test baselines are stored
48
- TEST_PERFORMANCE_BASELINE_FILE = "nautobot/core/tests/performance_baselines.yml"
49
47
 
50
48
  # Make Celery run synchronously (eager), to always store eager results, and run the broker in-memory.
51
49
  # NOTE: Celery does not honor the TASK_TRACK_STARTED config when running in eager mode, so the job result is not saved until after the task completes.
@@ -14,7 +14,6 @@ from django.db import connections
14
14
  from django.db.migrations.recorder import MigrationRecorder
15
15
  from django.test.runner import _init_worker, DiscoverRunner, ParallelTestSuite
16
16
  from django.test.utils import get_unique_databases_and_mirrors, NullTimeKeeper, override_settings
17
- import yaml
18
17
 
19
18
  from nautobot.core.celery import app, setup_nautobot_job_logging
20
19
  from nautobot.core.settings_funcs import parse_redis_connection
@@ -40,21 +39,25 @@ class NautobotParallelTestSuite(ParallelTestSuite):
40
39
 
41
40
  class NautobotTestRunner(DiscoverRunner):
42
41
  """
43
- Custom test runner that excludes integration tests by default.
42
+ Custom test runner that excludes (slow) integration and migration tests by default.
44
43
 
45
44
  This test runner is aware of our use of the "integration" tag and only runs integration tests if
46
45
  explicitly passed in with `nautobot-server test --tag integration`.
46
+ Similarly, it only runs migration tests if explicitly called with `--tag migration_test`.
47
47
 
48
48
  By Nautobot convention, integration tests must be tagged with "integration". The base
49
49
  `nautobot.core.testing.integration.SeleniumTestCase` has this tag, therefore any test cases
50
50
  inheriting from that class do not need to be explicitly tagged.
51
51
 
52
52
  Only integration tests that DO NOT inherit from `SeleniumTestCase` will need to be explicitly tagged.
53
+
54
+ Similarly, the `django-test-migrations` package `MigratorTestCase` base class has the tag `migration_test`, so
55
+ any subclasses thereof do not need to be explicitly tagged.
53
56
  """
54
57
 
55
58
  parallel_test_suite = NautobotParallelTestSuite
56
59
 
57
- exclude_tags = ["integration"]
60
+ exclude_tags = ["integration", "migration_test"]
58
61
 
59
62
  @classmethod
60
63
  def add_arguments(cls, parser):
@@ -75,15 +78,19 @@ class NautobotTestRunner(DiscoverRunner):
75
78
  self.cache_test_fixtures = cache_test_fixtures
76
79
  self.reusedb = reusedb
77
80
 
78
- # Assert "integration" hasn't been provided w/ --tag
79
81
  incoming_tags = kwargs.get("tags") or []
80
- # Assert "exclude_tags" hasn't been provided w/ --exclude-tag; else default to our own.
81
- incoming_exclude_tags = kwargs.get("exclude_tags") or []
82
+ exclude_tags = kwargs.get("exclude_tags") or []
83
+
84
+ for default_excluded_tag in self.exclude_tags:
85
+ if default_excluded_tag not in incoming_tags:
86
+ exclude_tags.append(default_excluded_tag)
87
+ # Can't just use self.log() here because we haven't yet called super().__init__()
88
+ if logger := kwargs.get("logger"):
89
+ logger.info("Implicitly excluding tests tagged %r", default_excluded_tag)
90
+ elif kwargs.get("verbosity", 1) >= 1:
91
+ print(f"Implicitly excluding tests tagged {default_excluded_tag!r}")
82
92
 
83
- # Only include our excluded tags if "integration" isn't provided w/ --tag
84
- if "integration" not in incoming_tags:
85
- incoming_exclude_tags.extend(self.exclude_tags)
86
- kwargs["exclude_tags"] = incoming_exclude_tags
93
+ kwargs["exclude_tags"] = exclude_tags
87
94
 
88
95
  super().__init__(**kwargs)
89
96
 
@@ -202,133 +209,3 @@ class NautobotTestRunner(DiscoverRunner):
202
209
  print(f"Database {db_name} emptied!")
203
210
 
204
211
  connection.creation.destroy_test_db(old_name, self.verbosity, self.keepdb)
205
-
206
-
207
- # Use django_slowtests only when GENERATE_PERFORMANCE_REPORT flag is set to true
208
- try:
209
- from django_slowtests.testrunner import DiscoverSlowestTestsRunner
210
-
211
- print("Using NautobotPerformanceTestRunner to run tests ...")
212
-
213
- class NautobotPerformanceTestRunner(NautobotTestRunner, DiscoverSlowestTestsRunner):
214
- """
215
- Pre-requisite:
216
- Set `GENERATE_PERFORMANCE_REPORT` to True in settings.py
217
- This test runner is designated to run performance specific unit tests.
218
-
219
- `ModelViewTestCase` is tagged with `performance` to test the time it will take to retrieve, list, create, bulk_create,
220
- delete, bulk_delete, edit, bulk_edit object(s) and various other operations.
221
-
222
- The results are compared to the corresponding entries in `TEST_PERFORMANCE_BASELINE_FILE` and only results that are significantly slower
223
- than baseline will be exposed to the user.
224
- """
225
-
226
- def generate_report(self, test_results, result):
227
- """
228
- Generate Performance Report consists of unit tests that are significantly slower than baseline.
229
- """
230
- test_result_count = len(test_results)
231
-
232
- # Add `--performance-snapshot` to the end of `invoke` commands to generate a report.json file consist of the performance tests result
233
- if self.report_path:
234
- data = [
235
- {
236
- "tests": [
237
- {
238
- "name": func_name,
239
- "execution_time": float(timing),
240
- }
241
- for func_name, timing in test_results
242
- ],
243
- "test_count": result.testsRun,
244
- "failed_count": len(result.errors + result.failures),
245
- "total_execution_time": result.timeTaken,
246
- }
247
- ]
248
- with open(self.report_path, "w") as outfile:
249
- yaml.dump(data, outfile, sort_keys=False)
250
- # Print the results in the CLI.
251
- else:
252
- if test_result_count:
253
- print(f"\n{test_result_count} abnormally slower tests:")
254
- for func_name, timing in test_results:
255
- time = float(timing)
256
- baseline = self.baselines.get(func_name, None)
257
- if baseline:
258
- baseline = float(baseline)
259
- print(f"{time:.4f}s {func_name} is significantly slower than the baseline {baseline:.4f}s")
260
- else:
261
- print(
262
- f"Performance baseline for {func_name} is not available. Test took {time:.4f}s to run"
263
- )
264
-
265
- if not test_results:
266
- print("\nNo tests signficantly slower than baseline. Success!")
267
-
268
- def get_baselines(self):
269
- """Load the performance_baselines.yml file for result comparison."""
270
- baselines = {}
271
- input_file = getattr(
272
- settings, "TEST_PERFORMANCE_BASELINE_FILE", "nautobot/core/tests/performance_baselines.yml"
273
- )
274
-
275
- with open(input_file) as f:
276
- data = yaml.safe_load(f)
277
- for entry in data["tests"]:
278
- baselines[entry["name"]] = entry["execution_time"]
279
- return baselines
280
-
281
- def suite_result(self, suite, result):
282
- """Compile the performance test results"""
283
- return_value = super(DiscoverSlowestTestsRunner, self).suite_result(suite, result)
284
- self.baselines = self.get_baselines()
285
-
286
- # add `--performance_report` to `invoke` commands to generate report.
287
- # e.g. `invoke unittest --performance_report`
288
- if not self.should_generate_report:
289
- self.remove_timing_tmp_files()
290
- return return_value
291
-
292
- # Grab slowest tests
293
- timings = self.get_timings()
294
- # Sort the results by test names x[0]
295
- by_name = sorted(timings, key=lambda x: x[0])
296
- test_results = by_name
297
-
298
- if self.baselines:
299
- # Filter tests by baseline numbers
300
- test_results = []
301
-
302
- for entry in by_name:
303
- # Convert test time from seconds to miliseconds for comparison
304
- result_time_ms = entry[1] * 1000
305
- # If self.report_path, that means the user wants to update the performance baselines.
306
- # so we append every result that is available to us.
307
- if self.report_path:
308
- test_results.append(entry)
309
- else:
310
- # If the test is completed under 1.5 times the baseline or the difference between the result and the baseline is less than 3 seconds,
311
- # dont show the test to the user.
312
-
313
- baseline = self.baselines.get(entry[0], None)
314
-
315
- # check if baseline is available
316
- if not baseline:
317
- test_results.append(entry)
318
- continue
319
-
320
- # baseline duration in milliseconds
321
- baseline_ms = baseline * 1000
322
- # Arbitrary criteria to not make performance test fail easily
323
- if result_time_ms <= baseline_ms * 1.5 or result_time_ms - baseline_ms <= 500:
324
- continue
325
-
326
- test_results.append(entry)
327
-
328
- self.generate_report(test_results, result)
329
- return return_value
330
-
331
- except ImportError:
332
- print(
333
- "Unable to import DiscoverSlowestTestsRunner from `django_slowtests`. Is the 'django_slowtests' package installed?"
334
- )