Przeglądaj źródła

fix(dcim): Prevent path rebuild when Cable Terminations unchanged

Compare Cable Terminations against stored values instead of empty cache
when checking for modifications. Freshly loaded cables have no cached
terminations, causing false positives that unnecessarily rebuild paths.
Add regression test coverage for resaving Cables and Termination
reassignment.

Fixes #23097
Martin Hauser 22 godzin temu
rodzic
commit
bfe51a3cfd

+ 467 - 59
contrib/openapi.json

@@ -21350,7 +21350,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21369,7 +21370,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21381,7 +21383,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21393,7 +21396,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21405,7 +21409,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21417,7 +21422,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21429,7 +21435,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21441,7 +21448,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21453,7 +21461,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21465,7 +21474,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21477,7 +21487,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -21489,7 +21500,8 @@
                         "schema": {
                             "type": "array",
                             "items": {
-                                "type": "string"
+                                "type": "string",
+                                "x-spec-enum-id": "97ed937d7f0040be"
                             }
                         },
                         "explode": true,
@@ -177111,15 +177123,27 @@
                         "in": "query",
                         "name": "cluster",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "cluster_group",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -177575,8 +177599,14 @@
                         "in": "query",
                         "name": "location",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -177881,22 +177911,40 @@
                         "in": "query",
                         "name": "rack",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "rack_group",
                         "schema": {
-                            "type": "number"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "region",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -178011,15 +178059,27 @@
                         "in": "query",
                         "name": "site",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
                         "name": "site_group",
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
                     },
                     {
                         "in": "query",
@@ -240113,6 +240173,42 @@
                     "user"
                 ]
             },
+            "BriefASN": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "asn": {
+                        "type": "integer",
+                        "maximum": 4294967295,
+                        "minimum": 1,
+                        "format": "int64",
+                        "description": "16- or 32-bit autonomous system number"
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "asn",
+                    "display",
+                    "id",
+                    "url"
+                ]
+            },
             "BriefCable": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -240786,6 +240882,57 @@
                     "url"
                 ]
             },
+            "BriefContactGroup": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from NestedGroupModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "slug": {
+                        "type": "string",
+                        "maxLength": 100,
+                        "pattern": "^[-a-zA-Z0-9_]+$"
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    },
+                    "contact_count": {
+                        "type": "integer",
+                        "readOnly": true,
+                        "default": 0
+                    },
+                    "_depth": {
+                        "type": "integer",
+                        "readOnly": true,
+                        "title": " depth"
+                    }
+                },
+                "required": [
+                    "_depth",
+                    "contact_count",
+                    "display",
+                    "id",
+                    "name",
+                    "slug",
+                    "url"
+                ]
+            },
             "BriefContactRequest": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -241301,6 +241448,39 @@
                     "protocol"
                 ]
             },
+            "BriefGroup": {
+                "type": "object",
+                "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 150
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefIKEPolicy": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -241352,6 +241532,39 @@
                     "name"
                 ]
             },
+            "BriefIKEProposal": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefIPAddress": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -241578,6 +241791,39 @@
                     "name"
                 ]
             },
+            "BriefIPSecProposal": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefInterface": {
                 "type": "object",
                 "description": "Adds an `owner` field for models which have a ForeignKey to users.Owner.",
@@ -242369,6 +242615,58 @@
                     "model"
                 ]
             },
+            "BriefObjectPermission": {
+                "type": "object",
+                "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 100
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    },
+                    "enabled": {
+                        "type": "boolean"
+                    },
+                    "object_types": {
+                        "type": "array",
+                        "items": {
+                            "type": "string"
+                        }
+                    },
+                    "actions": {
+                        "type": "array",
+                        "items": {
+                            "type": "string",
+                            "maxLength": 30
+                        },
+                        "description": "The list of actions granted by this permission"
+                    }
+                },
+                "required": [
+                    "actions",
+                    "display",
+                    "id",
+                    "name",
+                    "object_types",
+                    "url"
+                ]
+            },
             "BriefOwner": {
                 "type": "object",
                 "description": "Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during\nvalidation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)",
@@ -243386,6 +243684,40 @@
                     "slug"
                 ]
             },
+            "BriefRouteTarget": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "description": "Route target value (formatted in accordance with RFC 4360)",
+                        "maxLength": 21
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefSite": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -244327,6 +244659,49 @@
                     "slug"
                 ]
             },
+            "BriefVirtualDeviceContext": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "name": {
+                        "type": "string",
+                        "maxLength": 64
+                    },
+                    "device": {
+                        "$ref": "#/components/schemas/BriefDevice"
+                    },
+                    "identifier": {
+                        "type": "integer",
+                        "maximum": 32767,
+                        "minimum": 0,
+                        "nullable": true
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "device",
+                    "display",
+                    "id",
+                    "name",
+                    "url"
+                ]
+            },
             "BriefVirtualMachine": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from PrimaryModel.",
@@ -244442,6 +244817,39 @@
                     "slug"
                 ]
             },
+            "BriefWirelessLAN": {
+                "type": "object",
+                "description": "Base serializer class for models inheriting from PrimaryModel.",
+                "properties": {
+                    "id": {
+                        "type": "integer",
+                        "readOnly": true
+                    },
+                    "url": {
+                        "type": "string",
+                        "format": "uri",
+                        "readOnly": true
+                    },
+                    "display": {
+                        "type": "string",
+                        "readOnly": true
+                    },
+                    "ssid": {
+                        "type": "string",
+                        "maxLength": 32
+                    },
+                    "description": {
+                        "type": "string",
+                        "maxLength": 200
+                    }
+                },
+                "required": [
+                    "display",
+                    "id",
+                    "ssid",
+                    "url"
+                ]
+            },
             "BriefWirelessLANGroup": {
                 "type": "object",
                 "description": "Base serializer class for models inheriting from NestedGroupModel.",
@@ -260400,73 +260808,73 @@
                     "regions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Region"
+                            "$ref": "#/components/schemas/BriefRegion"
                         }
                     },
                     "site_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/SiteGroup"
+                            "$ref": "#/components/schemas/BriefSiteGroup"
                         }
                     },
                     "sites": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Site"
+                            "$ref": "#/components/schemas/BriefSite"
                         }
                     },
                     "locations": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Location"
+                            "$ref": "#/components/schemas/BriefLocation"
                         }
                     },
                     "device_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/DeviceType"
+                            "$ref": "#/components/schemas/BriefDeviceType"
                         }
                     },
                     "roles": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/DeviceRole"
+                            "$ref": "#/components/schemas/BriefDeviceRole"
                         }
                     },
                     "platforms": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Platform"
+                            "$ref": "#/components/schemas/BriefPlatform"
                         }
                     },
                     "cluster_types": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ClusterType"
+                            "$ref": "#/components/schemas/BriefClusterType"
                         }
                     },
                     "cluster_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ClusterGroup"
+                            "$ref": "#/components/schemas/BriefClusterGroup"
                         }
                     },
                     "clusters": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Cluster"
+                            "$ref": "#/components/schemas/BriefCluster"
                         }
                     },
                     "tenant_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/TenantGroup"
+                            "$ref": "#/components/schemas/BriefTenantGroup"
                         }
                     },
                     "tenants": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Tenant"
+                            "$ref": "#/components/schemas/BriefTenant"
                         }
                     },
                     "owner": {
@@ -262167,7 +262575,7 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ContactGroup"
+                            "$ref": "#/components/schemas/BriefContactGroup"
                         }
                     },
                     "name": {
@@ -267920,7 +268328,7 @@
                     "permissions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ObjectPermission"
+                            "$ref": "#/components/schemas/BriefObjectPermission"
                         }
                     },
                     "user_count": {
@@ -268036,7 +268444,7 @@
                     "proposals": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IKEProposal"
+                            "$ref": "#/components/schemas/BriefIKEProposal"
                         }
                     },
                     "preshared_key": {
@@ -269192,7 +269600,7 @@
                     "proposals": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IPSecProposal"
+                            "$ref": "#/components/schemas/BriefIPSecProposal"
                         }
                     },
                     "pfs_group": {
@@ -269977,7 +270385,7 @@
                     "vdcs": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VirtualDeviceContext"
+                            "$ref": "#/components/schemas/BriefVirtualDeviceContext"
                         }
                     },
                     "module": {
@@ -271132,7 +271540,7 @@
                     "tagged_vlans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VLAN"
+                            "$ref": "#/components/schemas/BriefVLAN"
                         }
                     },
                     "qinq_svlan": {
@@ -271199,7 +271607,7 @@
                     "wireless_lans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/WirelessLAN"
+                            "$ref": "#/components/schemas/BriefWirelessLAN"
                         }
                     },
                     "vrf": {
@@ -274131,13 +274539,13 @@
                     "import_targets": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/RouteTarget"
+                            "$ref": "#/components/schemas/BriefRouteTarget"
                         }
                     },
                     "export_targets": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/RouteTarget"
+                            "$ref": "#/components/schemas/BriefRouteTarget"
                         }
                     },
                     "description": {
@@ -277305,13 +277713,13 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "users": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/User"
+                            "$ref": "#/components/schemas/BriefUser"
                         }
                     }
                 },
@@ -277768,13 +278176,13 @@
                     "user_groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "users": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/User"
+                            "$ref": "#/components/schemas/BriefUser"
                         }
                     }
                 },
@@ -310992,7 +311400,7 @@
                     "asns": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ASN"
+                            "$ref": "#/components/schemas/BriefASN"
                         }
                     },
                     "tags": {
@@ -315135,7 +315543,7 @@
                     "ipaddresses": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/IPAddress"
+                            "$ref": "#/components/schemas/BriefIPAddress"
                         }
                     },
                     "description": {
@@ -315584,7 +315992,7 @@
                     "asns": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ASN"
+                            "$ref": "#/components/schemas/BriefASN"
                         }
                     },
                     "tags": {
@@ -317736,13 +318144,13 @@
                     "groups": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/Group"
+                            "$ref": "#/components/schemas/BriefGroup"
                         }
                     },
                     "permissions": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/ObjectPermission"
+                            "$ref": "#/components/schemas/BriefObjectPermission"
                         }
                     }
                 },
@@ -318649,7 +319057,7 @@
                     "tagged_vlans": {
                         "type": "array",
                         "items": {
-                            "$ref": "#/components/schemas/VLAN"
+                            "$ref": "#/components/schemas/BriefVLAN"
                         }
                     },
                     "qinq_svlan": {

+ 2 - 1
netbox/dcim/models/cables.py

@@ -244,7 +244,8 @@ class Cable(PrimaryModel):
                 ct.termination for ct in CableTermination.objects.filter(pk__in=value).prefetch_related('termination')
             ]
 
-        if not self.pk or getattr(self, _attr, []) != list(value):
+        # The private cache is empty on a freshly loaded cable, so compare against the stored terminations
+        if not self.pk or self._get_x_terminations(side) != list(value):
             self._terminations_modified = True
 
         setattr(self, _attr, value)

+ 46 - 0
netbox/dcim/tests/test_cablepaths.py

@@ -2892,6 +2892,52 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         interface3.refresh_from_db()
         self.assertPathIsNotSet(interface3)
 
+    def test_304_resave_cable_with_unchanged_terminations(self):
+        """
+        [IF1] --C1-- [IF2]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+
+        cable1 = Cable(
+            a_terminations=[interface1],
+            b_terminations=[interface2]
+        )
+        cable1.save()
+
+        path_pks = set(CablePath.objects.values_list('pk', flat=True))
+        termination_pks = set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True))
+        self.assertEqual(len(path_pks), 2)
+        self.assertEqual(len(termination_pks), 2)
+
+        # Reassign the same terminations, as the edit form does on every submission
+        cable1 = Cable.objects.get(pk=cable1.pk)
+        cable1.a_terminations = [interface1]
+        cable1.b_terminations = [interface2]
+        cable1.label = 'Renamed'
+        cable1.save()
+
+        self.assertEqual(set(CablePath.objects.values_list('pk', flat=True)), path_pks)
+        self.assertEqual(
+            set(CableTermination.objects.filter(cable=cable1).values_list('pk', flat=True)),
+            termination_pks
+        )
+
+        path1 = self.assertPathExists(
+            (interface1, cable1, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        path2 = self.assertPathExists(
+            (interface2, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+
     def test_401_exclude_midspan_devices(self):
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

+ 27 - 0
netbox/dcim/tests/test_models.py

@@ -2133,6 +2133,33 @@ class CableTestCase(TestCase):
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_reassigning_unchanged_terminations_does_not_flag_a_change(self):
+        """
+        Assigning the stored terminations to a freshly loaded cable must leave them unflagged.
+        """
+        interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+        interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+
+        # A cable loaded from the database has no cached terminations
+        cable = Cable.objects.first()
+        cable.a_terminations = [interface1]
+        cable.b_terminations = [interface2]
+
+        self.assertFalse(cable._terminations_modified)
+
+    def test_reassigning_different_terminations_flags_a_change(self):
+        """
+        Assigning a different termination to a freshly loaded cable must flag the change.
+        """
+        interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+        interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
+
+        cable = Cable.objects.first()
+        cable.a_terminations = [interface1]
+        cable.b_terminations = [interface3]
+
+        self.assertTrue(cable._terminations_modified)
+
     def test_partial_save_does_not_apply_an_unwritten_profile(self):
         """
         A save excluding profile must leave the terminations alone but keep the change pending.

+ 37 - 0
netbox/dcim/tests/test_views.py

@@ -4294,6 +4294,43 @@ class CableTestCase(
 
         return data
 
+    @tag('regression')  # Issue #23097
+    def test_edit_with_unchanged_terminations_preserves_paths(self):
+        """
+        Editing a cable without changing its terminations must leave its paths in place.
+        """
+        # The form's termination fields are restricted by view permission
+        self.add_permissions('dcim.change_cable', 'dcim.view_interface')
+
+        interface_a = Interface.objects.get(
+            device__name='Device 1', device__site__name='Site 1', name='Interface 1'
+        )
+        cable = interface_a.cable
+        interface_b = cable.b_terminations[0]
+        path_pks = set(CablePath.objects.filter(_nodes__contains=cable).values_list('pk', flat=True))
+        self.assertEqual(len(path_pks), 2)
+
+        data = {
+            'a_terminations': [interface_a.pk],
+            'b_terminations': [interface_b.pk],
+            'type': CableTypeChoices.TYPE_CAT6,
+            'status': LinkStatusChoices.STATUS_CONNECTED,
+            'label': 'Renamed',
+            'color': 'c0c0c0',
+        }
+        request = {
+            'path': self._get_url('edit', cable),
+            'data': post_data(data),
+        }
+        self.assertHttpStatus(self.client.post(**request), 302)
+
+        cable.refresh_from_db()
+        self.assertEqual(cable.label, 'Renamed')
+        self.assertEqual(
+            set(CablePath.objects.filter(_nodes__contains=cable).values_list('pk', flat=True)),
+            path_pks
+        )
+
 
 #
 # Connections