Repository: nautobot/nautobot

Issue:
Child locations up to 1 level down are able to have a site
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 1.4.5
* Python version: 3.8.10
* Database platform, version: Postgres 12.12
* Middleware(s):

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.

-->
### Steps to Reproduce
1. Create a parent location and attach it to a site
2. Create a child of the above parent, and attach to a site

<!-- What did you expect to happen? -->
### Expected Behavior
I expected to be unable to add a child location to a site as I am unable to do so with child locations two levels down, IE the child of a child location.

<!-- What happened instead? -->
### Observed Behavior
I was able to attach a child location to a site

![Screen Shot 2022-10-21 at 12 34 11 PM](https://user-images.githubusercontent.com/9521210/197245261-c76b8e63-5068-4887-bf2e-0d297924fa6f.png)
![Screen Shot 2022-10-21 at 12 34 58 PM](https://user-images.githubusercontent.com/9521210/197245405-2e9d44c7-3557-438d-8b44-9b1f301702a5.png)



Discussion:
Based on discussion on Slack, it sounds like this may have happened because the "Floor" `LocationType` was changed to no longer have the "Building" `LocationType` as its parent. We probably need to disallow changing the `parent` of a `LocationType` that's already in use?

Resolving patch:
diff --git a/nautobot/dcim/models/locations.py b/nautobot/dcim/models/locations.py
--- a/nautobot/dcim/models/locations.py
+++ b/nautobot/dcim/models/locations.py
@@ -70,6 +70,8 @@ def clean(self):
         Disallow LocationTypes whose name conflicts with existing location-related models, to avoid confusion.
 
         In the longer term we will collapse these other models into special cases of LocationType.
+
+        Also, disallow re-parenting a LocationType if there are Locations already using this LocationType.
         """
         super().clean()
 
@@ -85,6 +87,18 @@ def clean(self):
         ]:
             raise ValidationError({"name": "This name is reserved for future use."})
 
+        if (
+            self.present_in_database
+            and self.parent != LocationType.objects.get(pk=self.pk).parent
+            and self.locations.exists()
+        ):
+            raise ValidationError(
+                {
+                    "parent": "This LocationType currently has Locations using it, "
+                    "therefore its parent cannot be changed at this time."
+                }
+            )
+
     @property
     def display(self):
         """


Test patch:
diff --git a/nautobot/dcim/tests/test_models.py b/nautobot/dcim/tests/test_models.py
--- a/nautobot/dcim/tests/test_models.py
+++ b/nautobot/dcim/tests/test_models.py
@@ -603,6 +603,36 @@ def test_reserved_names(self):
                 LocationType(name=candidate_name).clean()
             self.assertIn("This name is reserved", str(cm.exception))
 
+    def test_changing_parent(self):
+        """Validate clean logic around changing the parent of a LocationType."""
+        parent = LocationType.objects.create(name="Parent LocationType")
+        child = LocationType.objects.create(name="Child LocationType")
+
+        # If there are no Locations using it yet, parent can be freely changed
+        child.parent = None
+        child.validated_save()
+        child.parent = parent
+        child.validated_save()
+
+        # Once there are Locations using it, parent cannot be changed.
+        site = Site.objects.create(name="Test Site", status=Status.objects.get_for_model(Site).first())
+        parent_loc = Location.objects.create(
+            name="Parent 1", location_type=parent, site=site, status=Status.objects.get_for_model(Location).first()
+        )
+        child_loc = Location.objects.create(
+            name="Child 1", location_type=child, parent=parent_loc, status=Status.objects.get_for_model(Location).last()
+        )
+        child.parent = None
+        with self.assertRaisesMessage(
+            ValidationError,
+            "This LocationType currently has Locations using it, therefore its parent cannot be changed at this time.",
+        ):
+            child.validated_save()
+
+        # If the locations are deleted, it again becomes re-parent-able.
+        child_loc.delete()
+        child.validated_save()
+
 
 class LocationTestCase(TestCase):
     def setUp(self):