nautobot 2.2.2__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.
- nautobot/apps/jobs.py +2 -0
- nautobot/core/api/utils.py +12 -9
- nautobot/core/apps/__init__.py +2 -2
- nautobot/core/celery/__init__.py +79 -68
- nautobot/core/celery/backends.py +9 -1
- nautobot/core/celery/control.py +4 -7
- nautobot/core/celery/schedulers.py +4 -2
- nautobot/core/celery/task.py +78 -5
- nautobot/core/graphql/schema.py +2 -1
- nautobot/core/jobs/__init__.py +2 -1
- nautobot/core/templates/generic/object_list.html +3 -3
- nautobot/core/templatetags/helpers.py +66 -9
- nautobot/core/testing/__init__.py +6 -1
- nautobot/core/testing/api.py +12 -13
- nautobot/core/testing/mixins.py +2 -2
- nautobot/core/testing/views.py +50 -51
- nautobot/core/tests/test_api.py +23 -2
- nautobot/core/tests/test_templatetags_helpers.py +32 -0
- nautobot/core/tests/test_views.py +19 -0
- nautobot/core/tests/test_views_utils.py +22 -1
- nautobot/core/utils/module_loading.py +89 -0
- nautobot/core/views/utils.py +3 -2
- nautobot/dcim/choices.py +14 -0
- nautobot/dcim/forms.py +51 -1
- nautobot/dcim/models/device_components.py +9 -5
- nautobot/dcim/templates/dcim/location.html +32 -13
- nautobot/dcim/templates/dcim/location_migrate_data_to_contact.html +102 -0
- nautobot/dcim/tests/test_views.py +137 -0
- nautobot/dcim/urls.py +5 -0
- nautobot/dcim/views.py +149 -1
- nautobot/extras/api/views.py +21 -10
- nautobot/extras/constants.py +3 -3
- nautobot/extras/datasources/git.py +47 -58
- nautobot/extras/forms/forms.py +3 -1
- nautobot/extras/jobs.py +79 -146
- nautobot/extras/models/datasources.py +0 -2
- nautobot/extras/models/jobs.py +36 -18
- nautobot/extras/plugins/__init__.py +1 -20
- nautobot/extras/signals.py +6 -9
- nautobot/extras/test_jobs/__init__.py +8 -0
- nautobot/extras/test_jobs/dry_run.py +3 -2
- nautobot/extras/test_jobs/fail.py +43 -0
- nautobot/extras/test_jobs/ipaddress_vars.py +40 -1
- nautobot/extras/test_jobs/jobs_module/__init__.py +5 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/__init__.py +1 -0
- nautobot/extras/test_jobs/jobs_module/jobs_submodule/jobs.py +6 -0
- nautobot/extras/test_jobs/pass.py +40 -0
- nautobot/extras/test_jobs/relative_import.py +11 -0
- nautobot/extras/tests/test_api.py +3 -0
- nautobot/extras/tests/test_datasources.py +125 -118
- nautobot/extras/tests/test_job_variables.py +57 -15
- nautobot/extras/tests/test_jobs.py +135 -1
- nautobot/extras/tests/test_models.py +26 -19
- nautobot/extras/tests/test_plugins.py +1 -3
- nautobot/extras/tests/test_views.py +2 -4
- nautobot/extras/views.py +47 -95
- nautobot/ipam/api/views.py +8 -1
- nautobot/ipam/graphql/types.py +11 -0
- nautobot/ipam/mixins.py +32 -0
- nautobot/ipam/models.py +2 -1
- nautobot/ipam/querysets.py +6 -1
- nautobot/ipam/tests/test_models.py +82 -0
- nautobot/project-static/docs/assets/extra.css +4 -0
- nautobot/project-static/docs/code-reference/nautobot/apps/api.html +1 -1
- nautobot/project-static/docs/code-reference/nautobot/apps/jobs.html +180 -211
- nautobot/project-static/docs/development/apps/api/platform-features/jobs.html +1 -1
- nautobot/project-static/docs/development/core/application-registry.html +126 -84
- nautobot/project-static/docs/development/core/model-checklist.html +49 -1
- nautobot/project-static/docs/development/core/model-features.html +1 -1
- nautobot/project-static/docs/development/jobs/index.html +334 -58
- nautobot/project-static/docs/development/jobs/migration/from-v1.html +1 -1
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.2.html +237 -55
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +254 -254
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/administration/upgrading/from-v1/upgrading-from-nautobot-v1.html +7 -4
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlan.html +111 -0
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/index.html +15 -28
- nautobot/project-static/docs/user-guide/platform-functionality/jobs/models.html +4 -4
- nautobot/project-static/js/forms.js +18 -11
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/METADATA +3 -3
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/RECORD +87 -81
- nautobot/extras/test_jobs/job_variables.py +0 -93
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/NOTICE +0 -0
- {nautobot-2.2.2.dist-info → nautobot-2.2.3.dist-info}/WHEEL +0 -0
- {nautobot-2.2.2.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
|
nautobot/core/views/utils.py
CHANGED
|
@@ -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
|
-
#
|
|
273
|
-
param_string =
|
|
273
|
+
# Encode the parameters into a URL query string
|
|
274
|
+
param_string = urllib.parse.urlencode(params)
|
|
274
275
|
|
|
275
276
|
return param_string
|
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,7 +53,7 @@ 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
58
|
from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VLANLocationAssignment, VRF
|
|
59
59
|
from nautobot.tenancy.forms import TenancyFilterForm, TenancyForm
|
|
@@ -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,
|
|
@@ -371,6 +372,55 @@ class LocationFilterForm(NautobotFilterForm, StatusModelFilterFormMixin, Tenancy
|
|
|
371
372
|
tags = TagFilterField(model)
|
|
372
373
|
|
|
373
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
|
+
|
|
374
424
|
#
|
|
375
425
|
# Rack groups
|
|
376
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",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
</div>
|
|
79
79
|
<div class="panel panel-default">
|
|
80
80
|
<div class="panel-heading">
|
|
81
|
-
<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 %}
|
|
@@ -25,6 +25,7 @@ from nautobot.dcim.choices import (
|
|
|
25
25
|
InterfaceModeChoices,
|
|
26
26
|
InterfaceRedundancyGroupProtocolChoices,
|
|
27
27
|
InterfaceTypeChoices,
|
|
28
|
+
LocationDataToContactActionChoices,
|
|
28
29
|
PortTypeChoices,
|
|
29
30
|
PowerFeedPhaseChoices,
|
|
30
31
|
PowerFeedSupplyChoices,
|
|
@@ -93,6 +94,8 @@ from nautobot.dcim.views import ConsoleConnectionsListView, InterfaceConnections
|
|
|
93
94
|
from nautobot.extras.choices import CustomFieldTypeChoices, RelationshipTypeChoices
|
|
94
95
|
from nautobot.extras.models import (
|
|
95
96
|
ConfigContextSchema,
|
|
97
|
+
Contact,
|
|
98
|
+
ContactAssociation,
|
|
96
99
|
CustomField,
|
|
97
100
|
CustomFieldChoice,
|
|
98
101
|
ExternalIntegration,
|
|
@@ -102,6 +105,7 @@ from nautobot.extras.models import (
|
|
|
102
105
|
SecretsGroup,
|
|
103
106
|
Status,
|
|
104
107
|
Tag,
|
|
108
|
+
Team,
|
|
105
109
|
)
|
|
106
110
|
from nautobot.ipam.choices import IPAddressTypeChoices
|
|
107
111
|
from nautobot.ipam.models import IPAddress, Namespace, Prefix, VLAN, VLANGroup, VRF
|
|
@@ -174,6 +178,8 @@ class LocationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
174
178
|
|
|
175
179
|
@classmethod
|
|
176
180
|
def setUpTestData(cls):
|
|
181
|
+
cls.contact_statuses = Status.objects.get_for_model(ContactAssociation)
|
|
182
|
+
cls.contact_roles = Role.objects.get_for_model(ContactAssociation)
|
|
177
183
|
lt1 = LocationType.objects.get(name="Campus")
|
|
178
184
|
lt2 = LocationType.objects.get(name="Building")
|
|
179
185
|
lt3 = LocationType.objects.get(name="Floor")
|
|
@@ -252,6 +258,137 @@ class LocationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|
|
252
258
|
self.assertHttpStatus(self.client.post(**request), 302)
|
|
253
259
|
self.assertEqual(Location.objects.get(name="Root 3").parent.pk, site_1.pk)
|
|
254
260
|
|
|
261
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
262
|
+
def test_migrate_location_data_from_location_assign(self):
|
|
263
|
+
self.add_permissions("dcim.change_location")
|
|
264
|
+
location = Location.objects.first()
|
|
265
|
+
location.contact_name = "Should be unique Contact Name"
|
|
266
|
+
location.contact_phone = "123123123"
|
|
267
|
+
location.contact_email = "helloword@example.com"
|
|
268
|
+
location.physical_address = "418 Brown Locks Barrettchester, NM 85792"
|
|
269
|
+
location.shipping_address = "53 blue Locks manchester, NY 12124"
|
|
270
|
+
similar_contact = Contact.objects.first()
|
|
271
|
+
role = self.contact_roles.first().pk
|
|
272
|
+
status = self.contact_statuses.first().pk
|
|
273
|
+
form_data = {
|
|
274
|
+
"action": LocationDataToContactActionChoices.ASSOCIATE_EXISTING_CONTACT,
|
|
275
|
+
"contact": similar_contact.pk,
|
|
276
|
+
"role": role,
|
|
277
|
+
"status": status,
|
|
278
|
+
}
|
|
279
|
+
request = {
|
|
280
|
+
"path": reverse("dcim:location_migrate_data_to_contact", kwargs={"pk": location.pk}),
|
|
281
|
+
"data": post_data(form_data),
|
|
282
|
+
}
|
|
283
|
+
# Assert permission checks are triggered
|
|
284
|
+
self.assertHttpStatus(self.client.post(**request), 200)
|
|
285
|
+
self.add_permissions("extras.add_contactassociation")
|
|
286
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
287
|
+
# assert ContactAssociation is created correctly
|
|
288
|
+
created_contact_association = ContactAssociation.objects.order_by("created").last()
|
|
289
|
+
self.assertEqual(created_contact_association.associated_object_id, location.pk)
|
|
290
|
+
self.assertEqual(created_contact_association.contact.pk, similar_contact.pk)
|
|
291
|
+
self.assertEqual(created_contact_association.role.pk, role)
|
|
292
|
+
self.assertEqual(created_contact_association.status.pk, status)
|
|
293
|
+
|
|
294
|
+
# assert location data is cleared out
|
|
295
|
+
location.refresh_from_db()
|
|
296
|
+
self.assertEqual(location.contact_name, "")
|
|
297
|
+
self.assertEqual(location.contact_phone, "")
|
|
298
|
+
self.assertEqual(location.contact_email, "")
|
|
299
|
+
|
|
300
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
301
|
+
def test_migrate_location_data_from_location_new_contact(self):
|
|
302
|
+
self.add_permissions("dcim.change_location")
|
|
303
|
+
location = Location.objects.first()
|
|
304
|
+
location.contact_name = "Should be unique Contact Name"
|
|
305
|
+
location.contact_phone = "123123123"
|
|
306
|
+
location.contact_email = "helloword@example.com"
|
|
307
|
+
location.physical_address = "418 Brown Locks Barrettchester, NM 85792"
|
|
308
|
+
location.shipping_address = "53 blue Locks manchester, NY 12124"
|
|
309
|
+
role = self.contact_roles.first().pk
|
|
310
|
+
status = self.contact_statuses.first().pk
|
|
311
|
+
form_data = {
|
|
312
|
+
"action": LocationDataToContactActionChoices.CREATE_AND_ASSIGN_NEW_CONTACT,
|
|
313
|
+
"name": "Should be unique Contact Name",
|
|
314
|
+
"phone": "123123123",
|
|
315
|
+
"email": "helloword@example.com",
|
|
316
|
+
"role": role,
|
|
317
|
+
"status": status,
|
|
318
|
+
}
|
|
319
|
+
request = {
|
|
320
|
+
"path": reverse("dcim:location_migrate_data_to_contact", kwargs={"pk": location.pk}),
|
|
321
|
+
"data": post_data(form_data),
|
|
322
|
+
}
|
|
323
|
+
# Assert permission checks are triggered
|
|
324
|
+
self.assertHttpStatus(self.client.post(**request), 200)
|
|
325
|
+
self.add_permissions("extras.add_contactassociation")
|
|
326
|
+
self.add_permissions("extras.add_contact")
|
|
327
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
328
|
+
# assert a new contact is created successfully
|
|
329
|
+
contact = Contact.objects.get(name="Should be unique Contact Name")
|
|
330
|
+
self.assertEqual(contact.name, form_data["name"])
|
|
331
|
+
self.assertEqual(contact.phone, form_data["phone"])
|
|
332
|
+
self.assertEqual(contact.email, form_data["email"])
|
|
333
|
+
# assert ContactAssociation is created correctly
|
|
334
|
+
created_contact_association = ContactAssociation.objects.order_by("created").last()
|
|
335
|
+
self.assertEqual(created_contact_association.associated_object_id, location.pk)
|
|
336
|
+
self.assertEqual(created_contact_association.contact.pk, contact.pk)
|
|
337
|
+
self.assertEqual(created_contact_association.role.pk, role)
|
|
338
|
+
self.assertEqual(created_contact_association.status.pk, status)
|
|
339
|
+
|
|
340
|
+
# assert location data is cleared out
|
|
341
|
+
location.refresh_from_db()
|
|
342
|
+
self.assertEqual(location.contact_name, "")
|
|
343
|
+
self.assertEqual(location.contact_phone, "")
|
|
344
|
+
self.assertEqual(location.contact_email, "")
|
|
345
|
+
|
|
346
|
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
|
|
347
|
+
def test_migrate_location_data_from_location_new_team(self):
|
|
348
|
+
self.add_permissions("dcim.change_location")
|
|
349
|
+
location = Location.objects.first()
|
|
350
|
+
location.contact_name = "Should be unique Team Name"
|
|
351
|
+
location.contact_phone = "123123123"
|
|
352
|
+
location.contact_email = "helloword@example.com"
|
|
353
|
+
location.physical_address = "418 Brown Locks Barrettchester, NM 85792"
|
|
354
|
+
location.shipping_address = "53 blue Locks manchester, NY 12124"
|
|
355
|
+
role = self.contact_roles.first().pk
|
|
356
|
+
status = self.contact_statuses.first().pk
|
|
357
|
+
form_data = {
|
|
358
|
+
"action": LocationDataToContactActionChoices.CREATE_AND_ASSIGN_NEW_TEAM,
|
|
359
|
+
"name": "Should be unique Team Name",
|
|
360
|
+
"phone": "123123123",
|
|
361
|
+
"email": "helloword@example.com",
|
|
362
|
+
"role": role,
|
|
363
|
+
"status": status,
|
|
364
|
+
}
|
|
365
|
+
request = {
|
|
366
|
+
"path": reverse("dcim:location_migrate_data_to_contact", kwargs={"pk": location.pk}),
|
|
367
|
+
"data": post_data(form_data),
|
|
368
|
+
}
|
|
369
|
+
# Assert permission checks are triggered
|
|
370
|
+
self.assertHttpStatus(self.client.post(**request), 200)
|
|
371
|
+
self.add_permissions("extras.add_contactassociation")
|
|
372
|
+
self.add_permissions("extras.add_team")
|
|
373
|
+
self.assertHttpStatus(self.client.post(**request), 302)
|
|
374
|
+
# assert a new team is created successfully
|
|
375
|
+
team = Team.objects.get(name="Should be unique Team Name")
|
|
376
|
+
self.assertEqual(team.name, form_data["name"])
|
|
377
|
+
self.assertEqual(team.phone, form_data["phone"])
|
|
378
|
+
self.assertEqual(team.email, form_data["email"])
|
|
379
|
+
# assert ContactAssociation is created correctly
|
|
380
|
+
created_contact_association = ContactAssociation.objects.order_by("created").last()
|
|
381
|
+
self.assertEqual(created_contact_association.associated_object_id, location.pk)
|
|
382
|
+
self.assertEqual(created_contact_association.team.pk, team.pk)
|
|
383
|
+
self.assertEqual(created_contact_association.role.pk, role)
|
|
384
|
+
self.assertEqual(created_contact_association.status.pk, status)
|
|
385
|
+
|
|
386
|
+
# assert location data is cleared out
|
|
387
|
+
location.refresh_from_db()
|
|
388
|
+
self.assertEqual(location.contact_name, "")
|
|
389
|
+
self.assertEqual(location.contact_phone, "")
|
|
390
|
+
self.assertEqual(location.contact_email, "")
|
|
391
|
+
|
|
255
392
|
|
|
256
393
|
class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|
257
394
|
model = RackGroup
|
nautobot/dcim/urls.py
CHANGED
|
@@ -84,6 +84,11 @@ urlpatterns = [
|
|
|
84
84
|
name="location_notes",
|
|
85
85
|
kwargs={"model": Location},
|
|
86
86
|
),
|
|
87
|
+
path(
|
|
88
|
+
"locations/<uuid:pk>/migrate-data-to-contact/",
|
|
89
|
+
views.MigrateLocationDataToContactView.as_view(),
|
|
90
|
+
name="location_migrate_data_to_contact",
|
|
91
|
+
),
|
|
87
92
|
path(
|
|
88
93
|
"locations/<uuid:object_id>/images/add/",
|
|
89
94
|
ImageAttachmentEditView.as_view(),
|