Repository: nautobot/nautobot

Issue:
Unable to re-name Racks
<!--
    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): v1.5.23
* Python version: 3.10
* Database platform, version: Postgres
* 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. Go to dcim/racks/
2. select any rack to edit
3. change the name of the rack

<!-- What did you expect to happen? -->
### Expected Behavior
Page would reload and name would be reflected. 

<!-- What happened instead? -->
### Observed Behavior
When submitting an error shows up below the `name` field:
```
Device with `name` in ['2.5 960GB SSD (PCIE Gen3) Samsung', '40GBE Active Fiber Cable - Aqua - 03M', '40GBE Active Fiber Cable - Aqua - 05M', ... '2.5 960GB SSD  Intel'] and site=A52Plus already exists.
```


From [Slack Conversation with Glenn M.](https://networktocode.slack.com/archives/C01NWPK6WHL/p1689953425513139?thread_ts=1689951178.929749&cid=C01NWPK6WHL) it is suspected that #4025 may have introduced this.
Set NAUTOBOT_INSTALLATION_METRICS_ENABLED=false in the dockerfile dev stage
<!--
    NOTE: This template is for use by maintainers only. Please do not submit
    an issue using this template unless you have been specifically asked to
    do so.
-->
### Proposed Changes

This should propagate to all of the app dev instances since they base off of nautobot dev images. Make sure to unset this in the final stage

<!-- Provide justification for the proposed change(s). -->
### Justification

We don't want to send metrics for dev environments


Discussion:

Resolving patch:
diff --git a/development/nautobot_config.py b/development/nautobot_config.py
--- a/development/nautobot_config.py
+++ b/development/nautobot_config.py
@@ -19,6 +19,9 @@
     # For the Docker dev environment, we don't know in advance what that IP may be, so override to skip that check
     DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda _request: DEBUG}
 
+# Do *not* send anonymized install metrics when post_upgrade or send_installation_metrics management commands are run
+INSTALLATION_METRICS_ENABLED = is_truthy(os.getenv("NAUTOBOT_INSTALLATION_METRICS_ENABLED", "False"))
+
 #
 # Logging for the development environment, taking into account the redefinition of DEBUG above
 #
diff --git a/nautobot/core/management/commands/send_installation_metrics.py b/nautobot/core/management/commands/send_installation_metrics.py
--- a/nautobot/core/management/commands/send_installation_metrics.py
+++ b/nautobot/core/management/commands/send_installation_metrics.py
@@ -1,6 +1,8 @@
 import hashlib
+import json
 import platform
 import requests
+import requests.exceptions
 import uuid
 
 from constance import config
@@ -57,19 +59,28 @@ def handle(self, *args, **options):
         }
 
         # send the payload to the metrics endpoint
+        self.stdout.write(self.style.SUCCESS(f"Sending installation metrics to '{METRICS_ENDPOINT}':"))
+        self.stdout.write(self.style.SUCCESS(json.dumps(payload, indent=4)))
         prepared_request = requests.Request("POST", METRICS_ENDPOINT, json=payload).prepare()
-        with requests.Session() as session:
-            response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
+        try:
+            with requests.Session() as session:
+                # fail after just over 3 seconds if unable to connect, and take no longer than 30 seconds in total
+                response = session.send(prepared_request, proxies=settings.HTTP_PROXIES, timeout=(3.05, 27))
 
-        if response.ok:
-            self.stdout.write(self.style.SUCCESS(f"Metrics successfully sent to '{METRICS_ENDPOINT}'"))
-        else:
+            if response.ok:
+                self.stdout.write(self.style.SUCCESS(f"Installation metrics successfully sent to '{METRICS_ENDPOINT}'"))
+            else:
+                self.stderr.write(
+                    self.style.ERROR(
+                        f"Failed to send installation metrics to '{METRICS_ENDPOINT}'; "
+                        f"response status {response.status_code}: {response.text}"
+                    )
+                )
+        except requests.exceptions.RequestException as exc:
+            self.stderr.write(self.style.ERROR(f"Failed to send installation metrics to '{METRICS_ENDPOINT}: {exc}"))
+        finally:
             self.stderr.write(
-                self.style.ERROR(
-                    f"Failed to send metrics to '{METRICS_ENDPOINT}'; "
-                    f"response status {response.status_code}: {response.content}"
+                self.style.NOTICE(
+                    "To disable installation metrics, set INSTALLATION_METRICS_ENABLED = False in your Nautobot config."
                 )
             )
-        self.stderr.write(
-            "To disable installation metrics, you can set INSTALLATION_METRICS_ENABLED = False in your Nautobot config."
-        )
diff --git a/nautobot/dcim/forms.py b/nautobot/dcim/forms.py
--- a/nautobot/dcim/forms.py
+++ b/nautobot/dcim/forms.py
@@ -5,7 +5,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms.array import SimpleArrayField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from django.db.models import Count, Q
+from django.db.models import Q
 from django.utils.safestring import mark_safe
 from netaddr import EUI
 from netaddr.core import AddrFormatError
@@ -696,21 +696,21 @@ def clean(self):
         cleaned_data = self.cleaned_data
         site = cleaned_data.get("site")
 
-        if self.instance:
+        if self.instance and self.instance.present_in_database and site != self.instance.site:
             # If the site is changed, the rack post save signal attempts to update the rack devices,
-            # which may result in an Exception.
-            # To avoid an unhandled exception in signal, catch this error here.
-            duplicate_devices_names = (
-                Device.objects.values_list("name", flat=True)
-                .annotate(name_count=Count("name"))
-                .filter(name_count__gt=1)
-            )
-            duplicate_devices = Device.objects.filter(site=site, name__in=list(duplicate_devices_names)).values_list(
-                "name", flat=True
-            )
+            # which may result in an Exception if the updated devices conflict with existing devices at this site.
+            # To avoid an unhandled exception in the signal, check for this scenario here.
+            duplicate_devices = set()
+            for device in self.instance.devices.all():
+                qs = Device.objects.exclude(pk=device.pk).filter(site=site, tenant=device.tenant, name=device.name)
+                if qs.exists():
+                    duplicate_devices.add(qs.first().name)
             if duplicate_devices:
                 raise ValidationError(
-                    {"site": f"Device with `name` in {list(duplicate_devices)} and site={site} already exists."}
+                    {
+                        "site": f"Device(s) {sorted(duplicate_devices)} already exist in site {site} and "
+                        "would conflict with same-named devices in this rack."
+                    }
                 )
         return cleaned_data
 


Test patch:
diff --git a/nautobot/dcim/tests/test_forms.py b/nautobot/dcim/tests/test_forms.py
--- a/nautobot/dcim/tests/test_forms.py
+++ b/nautobot/dcim/tests/test_forms.py
@@ -444,5 +444,21 @@ def test_update_rack_site(self):
         form = RackForm(data=data, instance=racks[0])
         self.assertEqual(
             str(form.errors.as_data()["site"][0]),
-            str(["Device with `name` in ['device1'] and site=Site-2 already exists."]),
+            str(
+                [
+                    "Device(s) ['device1'] already exist in site Site-2 and "
+                    "would conflict with same-named devices in this rack."
+                ]
+            ),
         )
+
+        # Check for https://github.com/nautobot/nautobot/issues/4149
+        data = {
+            "name": "New name",
+            "site": racks[0].site.pk,
+            "status": racks[0].status.pk,
+            "u_height": 48,
+            "width": RackWidthChoices.WIDTH_19IN,
+        }
+        form = RackForm(data=data, instance=racks[0])
+        self.assertTrue(form.is_valid())