nautobot 2.2.1__py3-none-any.whl → 2.2.3__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 (99) hide show
  1. nautobot/apps/jobs.py +2 -0
  2. nautobot/core/api/utils.py +12 -9
  3. nautobot/core/apps/__init__.py +2 -2
  4. nautobot/core/celery/__init__.py +79 -68
  5. nautobot/core/celery/backends.py +9 -1
  6. nautobot/core/celery/control.py +4 -7
  7. nautobot/core/celery/schedulers.py +4 -2
  8. nautobot/core/celery/task.py +78 -5
  9. nautobot/core/graphql/schema.py +2 -1
  10. nautobot/core/jobs/__init__.py +2 -1
  11. nautobot/core/templates/generic/object_list.html +3 -3
  12. nautobot/core/templatetags/helpers.py +66 -9
  13. nautobot/core/testing/__init__.py +6 -1
  14. nautobot/core/testing/api.py +12 -13
  15. nautobot/core/testing/mixins.py +2 -2
  16. nautobot/core/testing/views.py +50 -51
  17. nautobot/core/tests/test_api.py +23 -2
  18. nautobot/core/tests/test_templatetags_helpers.py +32 -0
  19. nautobot/core/tests/test_views.py +21 -1
  20. nautobot/core/tests/test_views_utils.py +22 -1
  21. nautobot/core/utils/module_loading.py +89 -0
  22. nautobot/core/views/generic.py +4 -4
  23. nautobot/core/views/mixins.py +4 -3
  24. nautobot/core/views/utils.py +3 -2
  25. nautobot/core/wsgi.py +9 -2
  26. nautobot/dcim/choices.py +14 -0
  27. nautobot/dcim/forms.py +59 -4
  28. nautobot/dcim/models/device_components.py +9 -5
  29. nautobot/dcim/templates/dcim/device/lldp_neighbors.html +2 -2
  30. nautobot/dcim/templates/dcim/devicefamily_retrieve.html +1 -1
  31. nautobot/dcim/templates/dcim/location.html +32 -13
  32. nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
  33. nautobot/dcim/tests/test_forms.py +49 -2
  34. nautobot/dcim/tests/test_views.py +137 -0
  35. nautobot/dcim/urls.py +5 -0
  36. nautobot/dcim/views.py +149 -1
  37. nautobot/extras/api/views.py +21 -10
  38. nautobot/extras/constants.py +3 -3
  39. nautobot/extras/context_managers.py +56 -0
  40. nautobot/extras/datasources/git.py +47 -58
  41. nautobot/extras/forms/forms.py +3 -1
  42. nautobot/extras/jobs.py +79 -146
  43. nautobot/extras/models/datasources.py +0 -2
  44. nautobot/extras/models/jobs.py +36 -18
  45. nautobot/extras/plugins/__init__.py +1 -20
  46. nautobot/extras/signals.py +88 -57
  47. nautobot/extras/test_jobs/__init__.py +8 -0
  48. nautobot/extras/test_jobs/dry_run.py +3 -2
  49. nautobot/extras/test_jobs/fail.py +43 -0
  50. nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
  51. nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
  52. nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
  53. nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
  54. nautobot/extras/test_jobs/pass.py +40 -0
  55. nautobot/extras/test_jobs/relative_import.py +11 -0
  56. nautobot/extras/tests/test_api.py +3 -0
  57. nautobot/extras/tests/test_context_managers.py +98 -1
  58. nautobot/extras/tests/test_datasources.py +125 -118
  59. nautobot/extras/tests/test_job_variables.py +57 -15
  60. nautobot/extras/tests/test_jobs.py +135 -1
  61. nautobot/extras/tests/test_models.py +26 -19
  62. nautobot/extras/tests/test_plugins.py +1 -3
  63. nautobot/extras/tests/test_views.py +2 -4
  64. nautobot/extras/utils.py +37 -0
  65. nautobot/extras/views.py +47 -95
  66. nautobot/ipam/api/views.py +8 -1
  67. nautobot/ipam/graphql/types.py +11 -0
  68. nautobot/ipam/mixins.py +32 -0
  69. nautobot/ipam/models.py +2 -1
  70. nautobot/ipam/querysets.py +6 -1
  71. nautobot/ipam/tables.py +1 -1
  72. nautobot/ipam/tests/test_models.py +82 -0
  73. nautobot/project-static/docs/assets/extra.css +4 -0
  74. nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
  75. nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
  76. nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
  77. nautobot/project-static/docs/development/core/application-registry.html +126 -84
  78. nautobot/project-static/docs/development/core/model-checklist.html +49 -1
  79. nautobot/project-static/docs/development/core/model-features.html +1 -1
  80. nautobot/project-static/docs/development/jobs/index.html +334 -58
  81. nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
  82. nautobot/project-static/docs/objects.inv +0 -0
  83. nautobot/project-static/docs/release-notes/version-1.6.html +504 -201
  84. nautobot/project-static/docs/release-notes/version-2.2.html +392 -43
  85. nautobot/project-static/docs/search/search_index.json +1 -1
  86. nautobot/project-static/docs/sitemap.xml +254 -254
  87. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  88. nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
  89. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
  90. nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
  91. nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
  92. nautobot/project-static/js/forms.js +18 -11
  93. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
  94. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/RECORD +98 -92
  95. nautobot/extras/test_jobs/job_variables.py +0 -93
  96. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
  97. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
  98. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
  99. {nautobot-2.2.1.dist-info → nautobot-2.2.3.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,10 @@
1
+ import urllib.parse
2
+
1
3
  from django.db import ProgrammingError
2
4
  from django.test import TestCase
3
5
 
4
6
  from nautobot.core.models.querysets import count_related
5
- from nautobot.core.views.utils import check_filter_for_display
7
+ from nautobot.core.views.utils import check_filter_for_display, prepare_cloned_fields
6
8
  from nautobot.dcim.filters import DeviceFilterSet
7
9
  from nautobot.dcim.models import Device, DeviceRedundancyGroup, DeviceType, InventoryItem, Location, Manufacturer
8
10
  from nautobot.extras.models import Role, Status
@@ -147,3 +149,22 @@ class CheckCountRelatedSubquery(TestCase):
147
149
  manufacturer_count=count_related(Manufacturer, "inventory_items__device", distinct=True)
148
150
  )
149
151
  self.assertEqual(qs.get(pk=device1.pk).manufacturer_count, 3)
152
+
153
+
154
+ class CheckPrepareClonedFields(TestCase):
155
+ name = "Building-02"
156
+ descriptions = ["Complicated Name & Stuff", "Simple Name"]
157
+
158
+ def testQueryParameterGeneration(self):
159
+ """Assert that a clone field with a special character, &, is properly escaped"""
160
+ instance = Location.objects.get(name=self.name)
161
+ self.assertIsInstance(instance, Location)
162
+ for description in self.descriptions:
163
+ with self.subTest(f"Testing parameter generation for a model with the name '{description}'"):
164
+ instance.description = description
165
+ query_params = urllib.parse.parse_qs(prepare_cloned_fields(instance))
166
+ self.assertTrue(isinstance(query_params, dict))
167
+ self.assertTrue("description" in query_params.keys())
168
+ self.assertTrue(isinstance(query_params["description"], list))
169
+ self.assertTrue(len(query_params["description"]) == 1)
170
+ self.assertTrue(query_params["description"][0] == description)
@@ -0,0 +1,89 @@
1
+ from contextlib import contextmanager
2
+ import importlib
3
+ from importlib.util import find_spec, module_from_spec
4
+ import logging
5
+ import os
6
+ import pkgutil
7
+ import sys
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ @contextmanager
13
+ def _temporarily_add_to_sys_path(path):
14
+ """
15
+ Allow loading of modules and packages from within the provided directory by temporarily modifying `sys.path`.
16
+
17
+ On exit, it restores the original `sys.path` value.
18
+ """
19
+ old_sys_path = sys.path.copy()
20
+ sys.path.insert(0, path)
21
+ try:
22
+ yield
23
+ finally:
24
+ sys.path = old_sys_path
25
+
26
+
27
+ def import_modules_privately(path, module_path=None, ignore_import_errors=True):
28
+ """
29
+ Import modules from the filesystem without adding the path permanently to `sys.path`.
30
+
31
+ This is used for importing Jobs from `JOBS_ROOT` and `GIT_ROOT` in such a way that they remain relatively
32
+ self-contained and can be easily discarded and reloaded on the fly.
33
+
34
+ If you find yourself writing new code that uses this method, please pause and reconsider your life choices.
35
+
36
+ Args:
37
+ path (str): Directory path possibly containing Python modules or packages to load.
38
+ module_path (list): If set to a non-empty list, only modules matching the given chain of modules will be loaded.
39
+ For example, `["my_git_repo", "jobs"]`.
40
+ ignore_import_errors (bool): Exceptions raised while importing modules will be caught and logged.
41
+ If this is set as False, they will then be re-raised to be handled by the caller of this function.
42
+ """
43
+ if module_path is None:
44
+ module_path = []
45
+ module_prefix = None
46
+ else:
47
+ module_prefix = ".".join(module_path)
48
+ with _temporarily_add_to_sys_path(path):
49
+ for finder, discovered_module_name, is_package in pkgutil.walk_packages([path], onerror=logger.error):
50
+ if module_prefix and not (
51
+ module_prefix.startswith(f"{discovered_module_name}.") # my_repo/__init__.py
52
+ or discovered_module_name == module_prefix # my_repo/jobs.py
53
+ or discovered_module_name.startswith(f"{module_prefix}.") # my_repo/jobs/foobar.py
54
+ ):
55
+ continue
56
+ try:
57
+ existing_module = find_spec(discovered_module_name)
58
+ except (ModuleNotFoundError, ValueError):
59
+ existing_module = None
60
+ if existing_module is not None:
61
+ existing_module_path = os.path.realpath(existing_module.origin)
62
+ if not existing_module_path.startswith(path):
63
+ logger.error(
64
+ "Unable to load module %s from %s as it conflicts with existing module %s",
65
+ discovered_module_name,
66
+ path,
67
+ existing_module_path,
68
+ )
69
+ continue
70
+
71
+ if discovered_module_name in sys.modules:
72
+ del sys.modules[discovered_module_name]
73
+
74
+ try:
75
+ if not is_package:
76
+ spec = finder.find_spec(discovered_module_name)
77
+ if spec is None:
78
+ raise ValueError("Unable to find module spec")
79
+ module = module_from_spec(spec)
80
+ sys.modules[discovered_module_name] = module
81
+ spec.loader.exec_module(module)
82
+ else:
83
+ module = importlib.import_module(discovered_module_name)
84
+
85
+ importlib.reload(module)
86
+ except Exception as exc:
87
+ logger.error("Unable to load module %s from %s: %s", discovered_module_name, path, exc)
88
+ if not ignore_import_errors:
89
+ raise
@@ -54,9 +54,10 @@ from nautobot.core.views.utils import (
54
54
  import_csv_helper,
55
55
  prepare_cloned_fields,
56
56
  )
57
+ from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
57
58
  from nautobot.extras.models import ContactAssociation, ExportTemplate
58
59
  from nautobot.extras.tables import AssociatedContactsTable
59
- from nautobot.extras.utils import remove_prefix_from_cf_key
60
+ from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, remove_prefix_from_cf_key
60
61
 
61
62
 
62
63
  class GenericView(LoginRequiredMixin, View):
@@ -988,7 +989,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
988
989
  nullified_fields = request.POST.getlist("_nullify")
989
990
 
990
991
  try:
991
- with transaction.atomic():
992
+ with deferred_change_logging_for_bulk_operation():
992
993
  updated_objects = []
993
994
  for obj in self.queryset.filter(pk__in=form.cleaned_data["pk"]):
994
995
  obj = self.alter_obj(obj, request, [], kwargs)
@@ -1252,13 +1253,12 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
1252
1253
 
1253
1254
  self.perform_pre_delete(request, queryset)
1254
1255
  try:
1255
- _, deleted_info = queryset.delete()
1256
+ _, deleted_info = bulk_delete_with_bulk_change_logging(queryset)
1256
1257
  deleted_count = deleted_info[model._meta.label]
1257
1258
  except ProtectedError as e:
1258
1259
  logger.info("Caught ProtectedError while attempting to delete objects")
1259
1260
  handle_protectederror(queryset, request, e)
1260
1261
  return redirect(self.get_return_url(request))
1261
-
1262
1262
  msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
1263
1263
  logger.info(msg)
1264
1264
  messages.success(request, msg)
@@ -45,10 +45,11 @@ from nautobot.core.views.utils import (
45
45
  import_csv_helper,
46
46
  prepare_cloned_fields,
47
47
  )
48
+ from nautobot.extras.context_managers import deferred_change_logging_for_bulk_operation
48
49
  from nautobot.extras.forms import NoteForm
49
50
  from nautobot.extras.models import ExportTemplate
50
51
  from nautobot.extras.tables import NoteTable, ObjectChangeTable
51
- from nautobot.extras.utils import remove_prefix_from_cf_key
52
+ from nautobot.extras.utils import bulk_delete_with_bulk_change_logging, remove_prefix_from_cf_key
52
53
 
53
54
  PERMISSIONS_ACTION_MAP = {
54
55
  "list": "view",
@@ -846,7 +847,7 @@ class ObjectBulkDestroyViewMixin(NautobotViewSetMixin, BulkDestroyModelMixin):
846
847
 
847
848
  try:
848
849
  with transaction.atomic():
849
- deleted_count = queryset.delete()[1][model._meta.label]
850
+ deleted_count = bulk_delete_with_bulk_change_logging(queryset)[1][model._meta.label]
850
851
  msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}"
851
852
  self.logger.info(msg)
852
853
  self.success_url = self.get_return_url(request)
@@ -978,7 +979,7 @@ class ObjectBulkUpdateViewMixin(NautobotViewSetMixin, BulkUpdateModelMixin):
978
979
  if field not in form_custom_fields + form_relationships + ["pk"] + ["object_note"]
979
980
  ]
980
981
  nullified_fields = request.POST.getlist("_nullify")
981
- with transaction.atomic():
982
+ with deferred_change_logging_for_bulk_operation():
982
983
  updated_objects = []
983
984
  for obj in queryset.filter(pk__in=form.cleaned_data["pk"]):
984
985
  self.obj = obj
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  from io import BytesIO
3
+ import urllib.parse
3
4
 
4
5
  from django.contrib import messages
5
6
  from django.core.exceptions import FieldError, ValidationError
@@ -269,7 +270,7 @@ def prepare_cloned_fields(instance):
269
270
  for tag in instance.tags.all():
270
271
  params.append(("tags", tag.pk))
271
272
 
272
- # Concatenate parameters into a URL query string
273
- param_string = "&".join([f"{k}={v}" for k, v in params])
273
+ # Encode the parameters into a URL query string
274
+ param_string = urllib.parse.urlencode(params)
274
275
 
275
276
  return param_string
nautobot/core/wsgi.py CHANGED
@@ -1,11 +1,18 @@
1
1
  import logging
2
- import os
3
2
 
4
3
  from django.core import cache
5
4
  from django.core.wsgi import get_wsgi_application
6
5
  from django.db import connections
7
6
 
8
- os.environ["DJANGO_SETTINGS_MODULE"] = "nautobot_config"
7
+ import nautobot
8
+
9
+ # This is the Django default left here for visibility on how the Nautobot pattern
10
+ # differs.
11
+ # os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nautobot.core.settings")
12
+
13
+ # Instead of just pointing to `DJANGO_SETTINGS_MODULE` and letting Django run with it,
14
+ # we're using the custom Nautobot loader code to read environment or config path for us.
15
+ nautobot.setup()
9
16
 
10
17
  # Use try/except because we might not be running uWSGI. If `settings.WEBSERVER_WARMUP` is `True`,
11
18
  # will first call `get_internal_wsgi_application` which does not have `uwsgi` module loaded
nautobot/dcim/choices.py CHANGED
@@ -21,6 +21,20 @@ class LocationStatusChoices(ChoiceSet):
21
21
  )
22
22
 
23
23
 
24
+ class LocationDataToContactActionChoices(ChoiceSet):
25
+ ASSOCIATE_EXISTING_CONTACT = "associate existing contact"
26
+ ASSOCIATE_EXISTING_TEAM = "associate existing team"
27
+ CREATE_AND_ASSIGN_NEW_CONTACT = "create and assign new contact"
28
+ CREATE_AND_ASSIGN_NEW_TEAM = "create and assign new team"
29
+
30
+ CHOICES = (
31
+ (ASSOCIATE_EXISTING_CONTACT, "Associate to existing contact"),
32
+ (ASSOCIATE_EXISTING_TEAM, "Associate to existing team"),
33
+ (CREATE_AND_ASSIGN_NEW_CONTACT, "Create and assign new contact"),
34
+ (CREATE_AND_ASSIGN_NEW_TEAM, "Create and assign new team"),
35
+ )
36
+
37
+
24
38
  #
25
39
  # Racks
26
40
  #
nautobot/dcim/forms.py CHANGED
@@ -53,9 +53,9 @@ from nautobot.extras.forms import (
53
53
  StatusModelFilterFormMixin,
54
54
  TagsBulkEditFormMixin,
55
55
  )
56
- from nautobot.extras.models import ExternalIntegration, SecretsGroup, Status
56
+ from nautobot.extras.models import Contact, ContactAssociation, ExternalIntegration, Role, SecretsGroup, Status, Team
57
57
  from nautobot.ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
58
- from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VRF
58
+ from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VLANLocationAssignment, VRF
59
59
  from nautobot.tenancy.forms import TenancyFilterForm, TenancyForm
60
60
  from nautobot.tenancy.models import Tenant, TenantGroup
61
61
  from nautobot.virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -69,6 +69,7 @@ from .choices import (
69
69
  InterfaceModeChoices,
70
70
  InterfaceRedundancyGroupProtocolChoices,
71
71
  InterfaceTypeChoices,
72
+ LocationDataToContactActionChoices,
72
73
  PortTypeChoices,
73
74
  PowerFeedPhaseChoices,
74
75
  PowerFeedSupplyChoices,
@@ -194,8 +195,13 @@ class InterfaceCommonForm(forms.Form):
194
195
  # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
195
196
  # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
196
197
  elif mode == InterfaceModeChoices.MODE_TAGGED:
197
- valid_locations = [None, self.cleaned_data[parent_field].location]
198
- invalid_vlans = [str(v) for v in tagged_vlans if v.location not in valid_locations]
198
+ valid_location = self.cleaned_data[parent_field].location
199
+ invalid_vlans = [
200
+ str(v)
201
+ for v in tagged_vlans
202
+ if v.locations.without_tree_fields().exists()
203
+ and not VLANLocationAssignment.objects.filter(location=valid_location, vlan=v).exists()
204
+ ]
199
205
 
200
206
  if invalid_vlans:
201
207
  raise forms.ValidationError(
@@ -366,6 +372,55 @@ class LocationFilterForm(NautobotFilterForm, StatusModelFilterFormMixin, Tenancy
366
372
  tags = TagFilterField(model)
367
373
 
368
374
 
375
+ class LocationMigrateDataToContactForm(NautobotModelForm):
376
+ # Assign tab form fields
377
+ action = forms.ChoiceField(
378
+ choices=LocationDataToContactActionChoices,
379
+ required=True,
380
+ widget=StaticSelect2(),
381
+ )
382
+ location = DynamicModelChoiceField(queryset=Location.objects.all(), required=False, label="Source Location")
383
+ contact = DynamicModelChoiceField(
384
+ queryset=Contact.objects.all(),
385
+ required=False,
386
+ label="Available Contacts",
387
+ query_params={"similar_to_location_data": "$location"},
388
+ )
389
+ team = DynamicModelChoiceField(
390
+ queryset=Team.objects.all(),
391
+ required=False,
392
+ label="Available Teams",
393
+ query_params={"similar_to_location_data": "$location"},
394
+ )
395
+ role = DynamicModelChoiceField(
396
+ queryset=Role.objects.all(),
397
+ required=True,
398
+ query_params={"content_types": ContactAssociation._meta.label_lower},
399
+ )
400
+ status = DynamicModelChoiceField(
401
+ queryset=Status.objects.all(),
402
+ required=True,
403
+ query_params={"content_types": ContactAssociation._meta.label_lower},
404
+ )
405
+ name = forms.CharField(required=False, label="Name")
406
+ phone = forms.CharField(required=False, label="Phone")
407
+ email = forms.CharField(required=False, label="Email")
408
+
409
+ class Meta:
410
+ model = ContactAssociation
411
+ fields = [
412
+ "action",
413
+ "location",
414
+ "contact",
415
+ "team",
416
+ "role",
417
+ "status",
418
+ "name",
419
+ "phone",
420
+ "email",
421
+ ]
422
+
423
+
369
424
  #
370
425
  # Rack groups
371
426
  #
@@ -205,6 +205,7 @@ class PathEndpoint(models.Model):
205
205
 
206
206
  @extras_features(
207
207
  "cable_terminations",
208
+ "custom_links",
208
209
  "custom_validators",
209
210
  "export_templates",
210
211
  "graphql",
@@ -236,7 +237,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel):
236
237
  #
237
238
 
238
239
 
239
- @extras_features("cable_terminations", "custom_validators", "graphql", "webhooks")
240
+ @extras_features("custom_links", "cable_terminations", "custom_validators", "graphql", "webhooks")
240
241
  class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
241
242
  """
242
243
  A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -265,6 +266,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel):
265
266
 
266
267
  @extras_features(
267
268
  "cable_terminations",
269
+ "custom_links",
268
270
  "custom_validators",
269
271
  "export_templates",
270
272
  "graphql",
@@ -380,7 +382,7 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel):
380
382
  #
381
383
 
382
384
 
383
- @extras_features("cable_terminations", "custom_validators", "graphql", "webhooks")
385
+ @extras_features("cable_terminations", "custom_links", "custom_validators", "graphql", "webhooks")
384
386
  class PowerOutlet(CableTermination, PathEndpoint, ComponentModel):
385
387
  """
386
388
  A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -490,6 +492,7 @@ class BaseInterface(RelationshipModel):
490
492
 
491
493
  @extras_features(
492
494
  "cable_terminations",
495
+ "custom_links",
493
496
  "custom_validators",
494
497
  "export_templates",
495
498
  "graphql",
@@ -892,7 +895,7 @@ class InterfaceRedundancyGroupAssociation(BaseModel, ChangeLoggedModel):
892
895
  #
893
896
 
894
897
 
895
- @extras_features("cable_terminations", "custom_validators", "graphql", "webhooks")
898
+ @extras_features("cable_terminations", "custom_links", "custom_validators", "graphql", "webhooks")
896
899
  class FrontPort(CableTermination, ComponentModel):
897
900
  """
898
901
  A pass-through port on the front of a Device.
@@ -936,7 +939,7 @@ class FrontPort(CableTermination, ComponentModel):
936
939
  )
937
940
 
938
941
 
939
- @extras_features("cable_terminations", "custom_validators", "graphql", "webhooks")
942
+ @extras_features("cable_terminations", "custom_links", "custom_validators", "graphql", "webhooks")
940
943
  class RearPort(CableTermination, ComponentModel):
941
944
  """
942
945
  A pass-through port on the rear of a Device.
@@ -978,7 +981,7 @@ class RearPort(CableTermination, ComponentModel):
978
981
  #
979
982
 
980
983
 
981
- @extras_features("custom_validators", "graphql", "webhooks")
984
+ @extras_features("custom_links", "custom_validators", "graphql", "webhooks")
982
985
  class DeviceBay(ComponentModel):
983
986
  """
984
987
  An empty space within a Device which can house a child device
@@ -1028,6 +1031,7 @@ class DeviceBay(ComponentModel):
1028
1031
 
1029
1032
 
1030
1033
  @extras_features(
1034
+ "custom_links",
1031
1035
  "custom_validators",
1032
1036
  "export_templates",
1033
1037
  "graphql",
@@ -65,7 +65,7 @@ var ready = (callback) => {
65
65
  };
66
66
 
67
67
  function getAttribute(node, querySelector, attribute) {
68
- if (node === null or node.querySelector(querySelector) === null) {
68
+ if (node === null || node.querySelector(querySelector) === null) {
69
69
  return "";
70
70
  }
71
71
  return node.querySelector(querySelector).getAttribute(attribute) || "";
@@ -131,4 +131,4 @@ ready(() => {
131
131
  });
132
132
 
133
133
  </script>
134
- {% endblock %}
134
+ {% endblock %}
@@ -20,7 +20,7 @@
20
20
  </tr>
21
21
  <tr>
22
22
  <td>Total Devices</td>
23
- <td>{{ total_devices }}</td>
23
+ <td><a href="{% url 'dcim:device_list' %}?device_family={{ object.name }}">{{ total_devices }}</a></td>
24
24
  </tr>
25
25
  </table>
26
26
  </div>
@@ -78,7 +78,7 @@
78
78
  </div>
79
79
  <div class="panel panel-default">
80
80
  <div class="panel-heading">
81
- <strong>Contact Info</strong>
81
+ <strong>Geographical Info</strong>
82
82
  </div>
83
83
  <table class="table table-hover panel-body attr-table">
84
84
  <tr>
@@ -115,20 +115,39 @@
115
115
  {% endif %}
116
116
  </td>
117
117
  </tr>
118
- <tr>
119
- <td>Contact Name</td>
120
- <td>{{ object.contact_name|placeholder }}</td>
121
- </tr>
122
- <tr>
123
- <td>Contact Phone</td>
124
- <td>{{ object.contact_phone|hyperlinked_phone_number }}</td>
125
- </tr>
126
- <tr>
127
- <td>Contact E-Mail</td>
128
- <td>{{ object.contact_email|hyperlinked_email }}</td>
129
- </tr>
130
118
  </table>
131
119
  </div>
120
+ {% if show_convert_to_contact_button %}
121
+ <div class="panel panel-default">
122
+ <div class="panel-heading">
123
+ <strong>Contact Info</strong>
124
+ </div>
125
+ <table class="table table-hover panel-body attr-table">
126
+ <tr>
127
+ <td>Contact Name</td>
128
+ <td>{{ object.contact_name|placeholder }}</td>
129
+ </tr>
130
+ <tr>
131
+ <td>Contact Phone</td>
132
+ <td>{{ object.contact_phone|hyperlinked_phone_number }}</td>
133
+ </tr>
134
+ <tr>
135
+ <td>Contact E-Mail</td>
136
+ <td>{{ object.contact_email|hyperlinked_email }}</td>
137
+ </tr>
138
+ </table>
139
+ {% if request.user|has_perms:contact_association_permission %}
140
+ {% with request.path|add:"?tab=contacts"|urlencode as return_url %}
141
+ <div class="panel-footer text-right noprint">
142
+ <a href="{% url 'dcim:location_migrate_data_to_contact' pk=object.pk %}?return_url={{return_url}}" class="btn btn-primary btn-xs">
143
+ <span class="mdi mdi-account-edit" aria-hidden="true"></span>
144
+ Convert to contact/team record
145
+ </a>
146
+ </div>
147
+ {% endwith %}
148
+ {% endif %}
149
+ </div>
150
+ {% endif %}
132
151
  <div class="panel panel-default">
133
152
  <div class="panel-heading">
134
153
  <strong>Comments</strong>
@@ -0,0 +1,102 @@
1
+ {% extends 'generic/object_create.html' %}
2
+ {% load form_helpers %}
3
+ {% load helpers %}
4
+
5
+ {% block title %}Migrating contact data from {{ obj_type }} {{ obj }}{% endblock %}
6
+ {% block form %}
7
+ <div id="assign" class="tabcontent">
8
+ <div class="panel panel-default">
9
+ <div class="panel-heading"><strong>Contact Data</strong></div>
10
+ <div class="panel-body">
11
+ {% render_field form.action %}
12
+ {% render_field form.location %}
13
+ <div class="form-group">
14
+ <label class="col-md-3 control-label required">Source Location "Contact Name"</label>
15
+ <div class="col-md-9">
16
+ <p class="form-control-static">{{ obj.contact_name | placeholder}}</p>
17
+ </div>
18
+ </div>
19
+ <div class="form-group">
20
+ <label class="col-md-3 control-label required">Source Location "Contact Phone"</label>
21
+ <div class="col-md-9">
22
+ <p class="form-control-static">{{ obj.contact_phone | placeholder}}</p>
23
+ </div>
24
+ </div>
25
+ <div class="form-group">
26
+ <label class="col-md-3 control-label required">Source Location "Contact Email"</label>
27
+ <div class="col-md-9">
28
+ <p class="form-control-static">{{ obj.contact_email | placeholder}}</p>
29
+ </div>
30
+ </div>
31
+ {% render_field form.name %}
32
+ {% render_field form.phone %}
33
+ {% render_field form.email %}
34
+ {% render_field form.contact %}
35
+ {% render_field form.team %}
36
+ {% render_field form.role %}
37
+ {% render_field form.status %}
38
+ </div>
39
+ </div>
40
+ </div>
41
+ {% endblock %}
42
+
43
+ {% block buttons %}
44
+ <button type="submit" name="_create" class="btn btn-primary">Assign</button>
45
+ <a href="{% url 'dcim:location' pk=obj.pk %}" class="btn btn-default">Cancel</a>
46
+ {% endblock %}
47
+
48
+ {% block javascript %}
49
+ <script>
50
+
51
+ // action drop down toggle
52
+ const action = document.getElementById("id_action");
53
+ function toggle_form_fields() {
54
+ const action_option = action.value;
55
+ document.getElementById("id_location").disabled = true;
56
+ const name = document.getElementById("id_name");
57
+ const name_label = document.querySelector("label[for=id_name]");
58
+ const phone = document.getElementById("id_phone");
59
+ const email = document.getElementById("id_email");
60
+ const similar_contacts_label = document.querySelector("label[for=id_contact]");
61
+ const similar_contacts = document.getElementById("id_contact");
62
+ const similar_teams_label = document.querySelector("label[for=id_team]");
63
+ const similar_teams = document.getElementById("id_team");
64
+
65
+ // Toggle these form fields when the action matches
66
+ similar_contacts.toggleAttribute("required", action_option=="associate existing contact");
67
+ similar_contacts_label.classList.toggle("required", action_option=="associate existing contact")
68
+ similar_teams.toggleAttribute("required", action_option=="associate existing team");
69
+ similar_teams_label.classList.toggle("required", action_option=="associate existing team")
70
+ name.toggleAttribute("disabled", action_option.match(/associate existing/));
71
+ name.toggleAttribute("required", action_option.match(/create and assign/));
72
+ name_label.classList.toggle("required", action_option.match(/create and assign/));
73
+ phone.toggleAttribute("disabled", action_option.match(/associate existing/));
74
+ email.toggleAttribute("disabled", action_option.match(/associate existing/));
75
+
76
+ // Show and hide form fields and toggle form field labels
77
+ if (action_option === "associate existing contact"){
78
+ similar_contacts.parentElement.parentElement.style.display = "block";
79
+ similar_teams.parentElement.parentElement.style.display = "none";
80
+ name.parentElement.parentElement.style.display = "none";
81
+ phone.parentElement.parentElement.style.display = "none";
82
+ email.parentElement.parentElement.style.display = "none";
83
+ } else if (action_option === "associate existing team"){
84
+ similar_teams.parentElement.parentElement.style.display = "block";
85
+ similar_contacts.parentElement.parentElement.style.display = "none";
86
+ name.parentElement.parentElement.style.display = "none";
87
+ phone.parentElement.parentElement.style.display = "none";
88
+ email.parentElement.parentElement.style.display = "none";
89
+ } else {
90
+ similar_contacts.parentElement.parentElement.style.display = "none";
91
+ similar_contacts.removeAttribute("required")
92
+ similar_teams.parentElement.parentElement.style.display = "none";
93
+ name.parentElement.parentElement.style.display = "block";
94
+ phone.parentElement.parentElement.style.display = "block";
95
+ email.parentElement.parentElement.style.display = "block";
96
+ }
97
+
98
+ }
99
+ window.onload = toggle_form_fields
100
+ action.onchange = toggle_form_fields
101
+ </script>
102
+ {% endblock %}