Repository: nautobot/nautobot

Issue:
View for associating locations to contacts and bulk creation
Follow up to #230

- [ ] Button on Location detail view to create and associate a Contact based on its current contact fields and it should be on the Contact Info panel footer
- Maybe investigate `difflib.get_close_matches` for detecting existing similar contacts and teams?
- [x] Management command to create Contacts from all existing Location contact fields  (if we are doing fuzzy matching with `difflib.get_close_matches`, maybe make the management command interactive?)\


Make sure we timebox to a 5. No need to get to 100% coverage here.




Discussion:

Resolving patch:
diff --git a/nautobot/dcim/management/commands/migrate_location_contacts.py b/nautobot/dcim/management/commands/migrate_location_contacts.py
new file mode 100644
--- /dev/null
+++ b/nautobot/dcim/management/commands/migrate_location_contacts.py
@@ -0,0 +1,218 @@
+from textwrap import dedent, indent
+
+from django.core.exceptions import ValidationError
+from django.core.management.base import BaseCommand
+from django.db import transaction
+
+from nautobot.dcim.models import Location
+from nautobot.extras.filters import ContactFilterSet, TeamFilterSet
+from nautobot.extras.models import Contact, ContactAssociation, Role, Status, Team
+
+
+class Command(BaseCommand):
+    help = "Migrate Location contact fields to Contact and Team objects."
+    verbose_help = """
+    This command will present a series of prompts to guide you through migrating Locations that
+    have data in the `contact_name`, `contact_phone`, or `contact_email` fields which are not
+    already associated to a Contact or Team. This command will give you the option to create new
+    Contacts or Teams or, if a similar Contact or Team already exists, to link the Location to the
+    existing Contact or Team. Note that when assigning a Location to an existing Contact or Team
+    that has a blank `phone` or `email` field, the value from the Location will be copied to the
+    Contact/Team. After a Location has been associated to a Contact or Team, the `contact_name`,
+    `contact_phone`, and `contact_email` fields will be cleared from the Location.
+    """
+
+    def handle(self, *args, **kwargs):
+        status_role_err_msg = "No {0} found for the ContactAssociation content type. Please ensure {0} are created before running this command."
+        if not Status.objects.get_for_model(ContactAssociation).exists():
+            self.stdout.write(self.style.ERROR(status_role_err_msg.format("statuses")))
+            return
+        if not Role.objects.get_for_model(ContactAssociation).exists():
+            self.stdout.write(self.style.ERROR(status_role_err_msg.format("roles")))
+            return
+
+        self.stdout.write(self.style.NOTICE(" ".join(dedent(self.verbose_help).split("\n"))))
+
+        try:
+            with transaction.atomic():
+                try:
+                    self.migrate_location_contacts()
+                except KeyboardInterrupt:
+                    while True:
+                        rollback = input("\nRoll back changes? [y/n]: ").strip().lower()
+                        if rollback == "y":
+                            raise
+                        elif rollback == "n":
+                            break
+        except KeyboardInterrupt:
+            self.stdout.write(self.style.ERROR("\nOperation cancelled, all changes rolled back."))
+        except:
+            self.stdout.write(self.style.ERROR("\nOperation failed, all changes rolled back."))
+            raise
+
+    def migrate_location_contacts(self):
+        """Iterate through Locations with contact information and try to match to existing Contact or Team."""
+        locations_with_contact_data = (
+            Location.objects.without_tree_fields()
+            .exclude(
+                contact_name="",
+                contact_phone="",
+                contact_email="",
+            )
+            .filter(associated_contacts__isnull=True)
+        )
+
+        for location in locations_with_contact_data:
+            self.stdout.write(f"Finding existing Contacts or Teams for location {location.display}...")
+            selected_contact = None
+            similar_contacts = list(ContactFilterSet(data={"similar_to_location_data": [location]}).qs)
+            similar_teams = list(TeamFilterSet(data={"similar_to_location_data": [location]}).qs)
+            similar_contacts_and_teams = similar_contacts + similar_teams
+
+            if not similar_contacts_and_teams:
+                self.stdout.write(
+                    self.style.WARNING(f"No similar Contacts or Teams found for location {location.display}.")
+                )
+
+            else:
+                # Found similar contacts or teams, prompt user for action
+                self.stdout.write("")
+                self.stdout.write(self.style.WARNING(f"Found similar contacts/teams for location {location.display}:"))
+                self.stdout.write(f"    current contact name: {location.contact_name!r}")
+                self.stdout.write(f"    current contact phone: {location.contact_phone!r}")
+                self.stdout.write(f"    current contact email: {location.contact_email!r}")
+                self.stdout.write("")
+
+                # Output menu of choices of valid contacts/teams
+                for i, contact in enumerate(similar_contacts_and_teams, start=1):
+                    self.stdout.write(f"{self.style.WARNING(i)}: {contact._meta.model_name.title()}: {contact.name}")
+                    self.print_contact_fields(contact, rjust=len(str(i)) + len(contact._meta.model_name) + 2)
+                    self.stdout.write("")
+
+            self.stdout.write(self.style.WARNING("c") + ": Create a new Contact")
+            self.stdout.write(self.style.WARNING("t") + ": Create a new Team")
+            self.stdout.write(self.style.WARNING("s") + ": Skip this location")
+
+            # Retrieve desired contact/team from user input
+            while True:
+                choice = input("Select a choice from the list of items: ")
+                if choice == "s":
+                    self.stdout.write(f"Skipping location {location.display}")
+                    break
+                elif choice == "c":
+                    selected_contact = self.create_new_contact_from_location(location, model=Contact)
+                    break
+                elif choice == "t":
+                    selected_contact = self.create_new_contact_from_location(location, model=Team)
+                    break
+                elif choice.lower() == "q":
+                    raise KeyboardInterrupt
+                elif choice.isdigit() and 0 < int(choice) <= len(similar_contacts_and_teams):
+                    selected_contact = similar_contacts_and_teams[int(choice) - 1]
+                    break
+
+            if selected_contact is not None:
+                self.associate_contact_to_location(selected_contact, location)
+
+    def associate_contact_to_location(self, contact, location):
+        role, status = self.prompt_for_role_and_status()
+
+        # If email or phone fields are present in the location but not the contact, update the contact fields
+        updated_fields = {}
+        if location.contact_phone and not contact.phone:
+            contact.phone = location.contact_phone
+            updated_fields["phone"] = location.contact_phone
+        if location.contact_email and not contact.email:
+            contact.email = location.contact_email
+            updated_fields["email"] = location.contact_email
+        if updated_fields:
+            try:
+                contact.validated_save()
+                self.stdout.write(
+                    self.style.SUCCESS(
+                        f"Updated {contact._meta.model_name.title()} {contact.name} field(s): {updated_fields!r}"
+                    )
+                )
+            except ValidationError as e:
+                contact.refresh_from_db()
+                self.stdout.write(
+                    self.style.ERROR(f"Attempted to update {contact!r} field(s) but failed: {updated_fields!r}: {e}")
+                )
+
+        try:
+            contact_association = ContactAssociation(
+                contact=contact if isinstance(contact, Contact) else None,
+                team=contact if isinstance(contact, Team) else None,
+                associated_object=location,
+                role=role,
+                status=status,
+            )
+            contact_association.validated_save()
+            location.contact_name = ""
+            location.contact_email = ""
+            location.contact_phone = ""
+            location.validated_save()
+        except Exception as e:
+            self.stdout.write(self.style.ERROR(f"Failed to create association: {e}"))
+        else:
+            self.stdout.write(self.style.SUCCESS(f"Associated {contact!r} to location {location.display}"))
+
+    def prompt_for_role_and_status(self):
+        # Prompt for role
+        self.stdout.write("\nValid roles for this association:")
+        valid_roles = list(Role.objects.get_for_model(ContactAssociation))
+        for i, role in enumerate(valid_roles, start=1):
+            self.stdout.write(self.style.WARNING(f"{i}") + f": {role}")
+        while True:
+            selected_role = input("Select a role for this association: ")
+            if selected_role.isdigit() and 0 < int(selected_role) <= len(valid_roles):
+                role = valid_roles[int(selected_role) - 1]
+                break
+
+        # Prompt for status
+        self.stdout.write("\nValid statuses for this association:")
+        valid_statuses = list(Status.objects.get_for_model(ContactAssociation))
+        for i, status in enumerate(valid_statuses, start=1):
+            self.stdout.write(self.style.WARNING(f"{i}") + f": {status}")
+        while True:
+            selected_status = input("Select a status for this association: ")
+            if selected_status.isdigit() and 0 < int(selected_status) <= len(valid_statuses):
+                status = valid_statuses[int(selected_status) - 1]
+                break
+
+        return role, status
+
+    def create_new_contact_from_location(self, location, model):
+        """Create a new Contact or Team from the location's contact data."""
+
+        self.stdout.write(f"Creating new {model._meta.model_name.title()} for location {location.display}...")
+        name = location.contact_name
+        phone = location.contact_phone
+        email = location.contact_email
+        self.stdout.write(f"    contact name: {name!r}")
+        self.stdout.write(f"    contact phone: {phone!r}")
+        self.stdout.write(f"    contact email: {email!r}")
+
+        while not name:
+            name = input(f"Name is required. Enter a name for the new {model._meta.model_name.title()}: ")
+
+        try:
+            contact = model(
+                name=name,
+                phone=phone,
+                email=email,
+            )
+            contact.validated_save()
+            return contact
+        except Exception as e:
+            self.stdout.write(self.style.ERROR(f"Failed to create {model._meta.model_name.title()}: {e}"))
+            return None
+
+    def print_contact_fields(self, contact, rjust=0):
+        for field_name in ["phone", "email"]:
+            if getattr(contact, field_name):
+                self.stdout.write(
+                    field_name.title().rjust(rjust)
+                    + ": "
+                    + indent(getattr(contact, field_name), " " * (rjust + 2)).lstrip()
+                )
diff --git a/nautobot/extras/filters/__init__.py b/nautobot/extras/filters/__init__.py
--- a/nautobot/extras/filters/__init__.py
+++ b/nautobot/extras/filters/__init__.py
@@ -1,7 +1,11 @@
+from difflib import get_close_matches
+
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
 import django_filters
+from drf_spectacular.utils import extend_schema_field
 
 from nautobot.core.api.exceptions import SerializerNotFound
 from nautobot.core.api.utils import get_serializer_for_model
@@ -447,7 +451,54 @@ class NautobotFilterSet(
 #
 
 
-class ContactFilterSet(NameSearchFilterSet, NautobotFilterSet):
+class ContactTeamFilterSet(NameSearchFilterSet, NautobotFilterSet):
+    """Base filter set for Contacts and Teams."""
+
+    similar_to_location_data = NaturalKeyOrPKMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        label="Similar to location contact data",
+        method="_similar_to_location_data",
+    )
+
+    def generate_query__similar_to_location_data(self, queryset, locations):
+        """Helper method used by _similar_to_location_data() method."""
+        query_params = Q()
+        for location in locations:
+            contact_name = location.contact_name
+            contact_phone = location.contact_phone
+            contact_email = location.contact_email
+            if contact_name:
+                contact_names = list(queryset.order_by().values_list("name", flat=True).distinct())
+                name_matches = get_close_matches(contact_name, contact_names, cutoff=0.8)
+                if name_matches:
+                    query_params |= Q(name__in=name_matches)
+            if contact_phone:
+                contact_phones = list(queryset.order_by().values_list("phone", flat=True).distinct())
+                phone_matches = get_close_matches(contact_phone, contact_phones, cutoff=0.8)
+                if phone_matches:
+                    query_params |= Q(phone__in=phone_matches)
+            if contact_email:
+                contact_emails = list(queryset.order_by().values_list("email", flat=True).distinct())
+                # fuzzy matching for emails doesn't make sense, use case insensitive match here
+                email_matches = [e for e in contact_emails if e.casefold() == contact_email.casefold()]
+                if email_matches:
+                    query_params |= Q(email__in=email_matches)
+
+        return query_params
+
+    @extend_schema_field({"type": "string"})
+    def _similar_to_location_data(self, queryset, name, value):
+        """FilterSet method for getting Contacts or Teams that are similar to the explicit contact fields of a location"""
+        if value:
+            params = self.generate_query__similar_to_location_data(queryset, value)
+            if len(params) > 0:
+                return queryset.filter(params)
+            else:
+                return queryset.none()
+        return queryset
+
+
+class ContactFilterSet(ContactTeamFilterSet):
     class Meta:
         model = Contact
         fields = "__all__"
@@ -1109,7 +1160,7 @@ class Meta:
 #
 
 
-class TeamFilterSet(NameSearchFilterSet, NautobotFilterSet):
+class TeamFilterSet(ContactTeamFilterSet):
     class Meta:
         model = Team
         fields = "__all__"


Test patch:
diff --git a/nautobot/extras/tests/test_filters.py b/nautobot/extras/tests/test_filters.py
--- a/nautobot/extras/tests/test_filters.py
+++ b/nautobot/extras/tests/test_filters.py
@@ -61,6 +61,7 @@
     ComputedField,
     ConfigContext,
     Contact,
+    ContactAssociation,
     CustomField,
     CustomFieldChoice,
     CustomLink,
@@ -457,7 +458,91 @@ def test_search(self):
         )
 
 
-class ContactFilterSetTestCase(FilterTestCases.FilterTestCase):
+class ContactAndTeamFilterSetTestCaseMixin:
+    """Mixin class to test common filters to both Contact and Team filter sets."""
+
+    def test_similar_to_location_data(self):
+        """Complex test to test the complex `similar_to_location_data` method filter."""
+        ContactAssociation.objects.all().delete()
+        self.queryset.delete()
+        location_type = LocationType.objects.filter(parent__isnull=True).first()
+        location_status = Status.objects.get_for_model(Location).first()
+        test_locations = (
+            Location.objects.create(
+                location_type=location_type,
+                name="Filter Test Location 0",
+                status=location_status,
+                contact_name="match 0",
+            ),
+            Location.objects.create(
+                location_type=location_type,
+                name="Filter Test Location 1",
+                status=location_status,
+                contact_email="Test email for location 1 and 2",
+            ),
+            Location.objects.create(
+                location_type=location_type,
+                name="Filter Test Location 2",
+                status=location_status,
+                contact_email="TEST EMAIL FOR LOCATION 1 AND 2",
+                contact_phone="Test phone for location 2 and 3",
+            ),
+            Location.objects.create(
+                location_type=location_type,
+                name="Filter Test Location 3",
+                status=location_status,
+                contact_phone="Test phone for location 2 and 3",
+            ),
+            Location.objects.create(
+                location_type=location_type,
+                name="Filter Test Location 4",
+                status=location_status,
+                contact_name="Hopefully this doesn't match any random factory data",
+                contact_email="Hopefully this doesn't match any random factory data",
+                contact_phone="Hopefully this doesn't match any random factory data",
+                physical_address="Hopefully this doesn't match any random factory data",
+                shipping_address="Hopefully this doesn't match any random factory data",
+            ),
+        )
+
+        self.queryset.create(name="match 0")
+        self.queryset.create(name="match 1 and 2", email="Test email for location 1 and 2")
+        self.queryset.create(name="match 2 and 3", phone="Test phone for location 2 and 3")
+
+        # These subtests are confusing because we're trying to test the NaturalKeyOrPKMultipleChoiceFilter
+        # behavior while also testing the `similar_to_location_data` method filter behavior.
+        with self.subTest("Test name match"):
+            params = {"similar_to_location_data": [test_locations[0].pk]}
+            self.assertQuerysetEqualAndNotEmpty(
+                self.filterset(params, self.queryset).qs,
+                self.queryset.filter(name__in=["match 0"]),
+            )
+        with self.subTest("Test email match"):
+            params = {"similar_to_location_data": [test_locations[1].pk]}
+            self.assertQuerysetEqualAndNotEmpty(
+                self.filterset(params, self.queryset).qs,
+                self.queryset.filter(name__in=["match 1 and 2"]),
+            )
+        with self.subTest("Test phone match"):
+            params = {"similar_to_location_data": [test_locations[2].pk]}
+            self.assertQuerysetEqualAndNotEmpty(
+                self.filterset(params, self.queryset).qs,
+                self.queryset.filter(name__in=["match 1 and 2", "match 2 and 3"]),
+            )
+        with self.subTest("Test email and phone match"):
+            params = {"similar_to_location_data": [test_locations[1].pk, test_locations[3].name]}
+            self.assertQuerysetEqualAndNotEmpty(
+                self.filterset(params, self.queryset).qs,
+                self.queryset.filter(name__in=["match 1 and 2", "match 2 and 3"]),
+            )
+        with self.subTest("Test no match"):
+            params = {"similar_to_location_data": [test_locations[4].pk]}
+            self.assertFalse(self.filterset(params, self.queryset).qs.exists())
+            params = {"similar_to_location_data": [test_locations[4].name]}
+            self.assertFalse(self.filterset(params, self.queryset).qs.exists())
+
+
+class ContactFilterSetTestCase(ContactAndTeamFilterSetTestCaseMixin, FilterTestCases.FilterTestCase):
     queryset = Contact.objects.all()
     filterset = ContactFilterSet
 
@@ -1756,7 +1841,7 @@ def test_search(self):
         self.assertEqual(self.filterset(params, self.queryset).qs.values_list("pk", flat=True)[0], value)
 
 
-class TeamFilterSetTestCase(FilterTestCases.FilterTestCase):
+class TeamFilterSetTestCase(ContactAndTeamFilterSetTestCaseMixin, FilterTestCases.FilterTestCase):
     queryset = Team.objects.all()
     filterset = TeamFilterSet