Răsfoiți Sursa

merge feature

Arthur 2 luni în urmă
părinte
comite
113c8b7ae6
100 a modificat fișierele cu 6520 adăugiri și 4121 ștergeri
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/workflows/ci.yml
  4. 521 1
      contrib/openapi.json
  5. 1 1
      docs/development/models.md
  6. 3 4
      docs/plugins/development/migration-v4.md
  7. 17 0
      docs/release-notes/version-4.4.md
  8. 6 5
      netbox/circuits/graphql/filter_mixins.py
  9. 14 24
      netbox/circuits/graphql/filters.py
  10. 3 16
      netbox/core/graphql/filter_mixins.py
  11. 5 9
      netbox/core/graphql/filters.py
  12. 52 0
      netbox/dcim/api/serializers_/base.py
  13. 43 18
      netbox/dcim/api/serializers_/device_components.py
  14. 45 8
      netbox/dcim/api/serializers_/devicetype_components.py
  15. 2 2
      netbox/dcim/constants.py
  16. 33 4
      netbox/dcim/filtersets.py
  17. 18 31
      netbox/dcim/forms/bulk_import.py
  18. 22 11
      netbox/dcim/forms/filtersets.py
  19. 76 1
      netbox/dcim/forms/mixins.py
  20. 90 25
      netbox/dcim/forms/model_forms.py
  21. 19 122
      netbox/dcim/forms/object_create.py
  22. 21 21
      netbox/dcim/forms/object_import.py
  23. 10 12
      netbox/dcim/graphql/filter_mixins.py
  24. 92 65
      netbox/dcim/graphql/filters.py
  25. 28 4
      netbox/dcim/graphql/types.py
  26. 10 8
      netbox/dcim/migrations/0216_latitude_longitude_validators.py
  27. 219 0
      netbox/dcim/migrations/0222_port_mappings.py
  28. 65 0
      netbox/dcim/migrations/0223_frontport_positions.py
  29. 1 1
      netbox/dcim/migrations/0224_add_comments_to_organizationalmodel.py
  30. 61 0
      netbox/dcim/models/base.py
  31. 66 48
      netbox/dcim/models/cables.py
  32. 84 48
      netbox/dcim/models/device_component_templates.py
  33. 58 46
      netbox/dcim/models/device_components.py
  34. 13 7
      netbox/dcim/models/devices.py
  35. 0 2
      netbox/dcim/models/modules.py
  36. 10 2
      netbox/dcim/models/sites.py
  37. 13 12
      netbox/dcim/signals.py
  38. 22 18
      netbox/dcim/tables/devices.py
  39. 10 5
      netbox/dcim/tables/devicetypes.py
  40. 252 62
      netbox/dcim/tests/test_api.py
  41. 649 254
      netbox/dcim/tests/test_cablepaths.py
  42. 302 47
      netbox/dcim/tests/test_cablepaths2.py
  43. 157 59
      netbox/dcim/tests/test_filtersets.py
  44. 4 2
      netbox/dcim/tests/test_forms.py
  45. 70 11
      netbox/dcim/tests/test_models.py
  46. 82 59
      netbox/dcim/tests/test_views.py
  47. 33 0
      netbox/dcim/utils.py
  48. 13 0
      netbox/dcim/views.py
  49. 4 14
      netbox/extras/graphql/filter_mixins.py
  50. 20 18
      netbox/extras/graphql/filters.py
  51. 25 11
      netbox/extras/jobs.py
  52. 4 5
      netbox/ipam/graphql/filter_mixins.py
  53. 22 21
      netbox/ipam/graphql/filters.py
  54. 4 49
      netbox/netbox/graphql/filter_mixins.py
  55. 62 0
      netbox/netbox/graphql/filters.py
  56. 7 3
      netbox/netbox/views/generic/object_views.py
  57. 0 0
      netbox/project-static/dist/netbox.css
  58. 0 0
      netbox/project-static/dist/netbox.js
  59. 0 0
      netbox/project-static/dist/netbox.js.map
  60. 1 1
      netbox/project-static/package.json
  61. 52 11
      netbox/project-static/src/buttons/moveOptions.ts
  62. 14 0
      netbox/project-static/styles/transitional/_forms.scss
  63. 4 4
      netbox/project-static/yarn.lock
  64. 2 2
      netbox/release.yaml
  65. 26 7
      netbox/templates/dcim/frontport.html
  66. 24 1
      netbox/templates/dcim/rearport.html
  67. 2 4
      netbox/tenancy/graphql/filter_mixins.py
  68. 8 11
      netbox/tenancy/graphql/filters.py
  69. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  70. 190 190
      netbox/translations/cs/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  72. 190 190
      netbox/translations/da/LC_MESSAGES/django.po
  73. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  74. 189 189
      netbox/translations/de/LC_MESSAGES/django.po
  75. 192 192
      netbox/translations/en/LC_MESSAGES/django.po
  76. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  77. 190 190
      netbox/translations/es/LC_MESSAGES/django.po
  78. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  79. 190 190
      netbox/translations/fr/LC_MESSAGES/django.po
  80. BIN
      netbox/translations/it/LC_MESSAGES/django.mo
  81. 190 190
      netbox/translations/it/LC_MESSAGES/django.po
  82. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  83. 192 192
      netbox/translations/ja/LC_MESSAGES/django.po
  84. BIN
      netbox/translations/nl/LC_MESSAGES/django.mo
  85. 190 190
      netbox/translations/nl/LC_MESSAGES/django.po
  86. BIN
      netbox/translations/pl/LC_MESSAGES/django.mo
  87. 190 190
      netbox/translations/pl/LC_MESSAGES/django.po
  88. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  89. 190 190
      netbox/translations/pt/LC_MESSAGES/django.po
  90. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  91. 192 192
      netbox/translations/ru/LC_MESSAGES/django.po
  92. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  93. 190 190
      netbox/translations/tr/LC_MESSAGES/django.po
  94. BIN
      netbox/translations/uk/LC_MESSAGES/django.mo
  95. 192 192
      netbox/translations/uk/LC_MESSAGES/django.po
  96. BIN
      netbox/translations/zh/LC_MESSAGES/django.mo
  97. 192 192
      netbox/translations/zh/LC_MESSAGES/django.po
  98. 21 4
      netbox/users/forms/model_forms.py
  99. 5 5
      netbox/users/graphql/filters.py
  100. 36 13
      netbox/utilities/forms/widgets/select.py

+ 1 - 1
.github/ISSUE_TEMPLATE/01-feature_request.yaml

@@ -15,7 +15,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.4.7
+      placeholder: v4.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/02-bug_report.yaml

@@ -27,7 +27,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.4.7
+      placeholder: v4.4.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/workflows/ci.yml

@@ -31,7 +31,7 @@ jobs:
       NETBOX_CONFIGURATION: netbox.configuration_testing
       NETBOX_CONFIGURATION: netbox.configuration_testing
     strategy:
     strategy:
       matrix:
       matrix:
-        python-version: ['3.12', '3.13']
+        python-version: ['3.12', '3.13', '3.14']
         node-version: ['20.x']
         node-version: ['20.x']
     services:
     services:
       redis:
       redis:

+ 521 - 1
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "openapi": "3.0.3",
     "info": {
     "info": {
         "title": "NetBox REST API",
         "title": "NetBox REST API",
-        "version": "4.4.7",
+        "version": "4.4.8",
         "license": {
         "license": {
             "name": "Apache v2 License"
             "name": "Apache v2 License"
         }
         }
@@ -27997,6 +27997,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",
@@ -31608,6 +31660,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",
@@ -35022,6 +35126,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "updated_by_request",
                         "name": "updated_by_request",
@@ -47623,6 +47779,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",
@@ -53692,6 +53900,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "tx_power",
                         "name": "tx_power",
@@ -60311,6 +60571,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "updated_by_request",
                         "name": "updated_by_request",
@@ -68666,6 +68978,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "updated_by_request",
                         "name": "updated_by_request",
@@ -81283,6 +81647,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",
@@ -86612,6 +87028,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",
@@ -99835,6 +100303,58 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "tenant",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "Tenant (slug)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "tenant_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Tenant (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "type",
                         "name": "type",

+ 1 - 1
docs/development/models.md

@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
 
 
 | Feature                                                    | Feature Mixin           | Registry Key        | Description                                                                             |
 | Feature                                                    | Feature Mixin           | Registry Key        | Description                                                                             |
 |------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
 |------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
-| [Bookmarks](../features/customization.md#bookmarks)        | `BookmarksMixin`        | `bookmarks`         | These models can be bookmarked natively in the user interface                           |
+| [Bookmarks](../features/user-preferences.md#bookmarks)     | `BookmarksMixin`        | `bookmarks`         | These models can be bookmarked natively in the user interface                           |
 | [Change logging](../features/change-logging.md)            | `ChangeLoggingMixin`    | `change_logging`    | Changes to these objects are automatically recorded in the change log                   |
 | [Change logging](../features/change-logging.md)            | `ChangeLoggingMixin`    | `change_logging`    | Changes to these objects are automatically recorded in the change log                   |
 | Cloning                                                    | `CloningMixin`          | `cloning`           | Provides the `clone()` method to prepare a copy                                         |
 | Cloning                                                    | `CloningMixin`          | `cloning`           | Provides the `clone()` method to prepare a copy                                         |
 | [Contacts](../features/contacts.md)                        | `ContactsMixin`         | `contacts`          | Contacts can be associated with these models                                            |
 | [Contacts](../features/contacts.md)                        | `ContactsMixin`         | `contacts`          | Contacts can be associated with these models                                            |

+ 3 - 4
docs/plugins/development/migration-v4.md

@@ -325,14 +325,14 @@ class CircuitTypeType(OrganizationalObjectType):
 
 
 ### Change filters.py
 ### Change filters.py
 
 
-Strawberry currently doesn't directly support django-filter, so an explicit filters.py file will need to be created.  NetBox includes a new `autotype_decorator` used to automatically wrap FilterSets to reduce the required code to a minimum.
+Filter classes should inherit from `netbox.graphql.filters.BaseModelFilter`.
 
 
 ```python title="New"
 ```python title="New"
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 from circuits import filtersets, models
 from circuits import filtersets, models
 
 
-from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+from netbox.graphql.filters import BaseModelFilter
 
 
 __all__ = (
 __all__ = (
     'CircuitFilter',
     'CircuitFilter',
@@ -340,8 +340,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter(models.Circuit, lookups=True)
 @strawberry_django.filter(models.Circuit, lookups=True)
-@autotype_decorator(filtersets.CircuitFilterSet)
-class CircuitFilter(BaseFilterMixin):
+class CircuitFilter(BaseModelFilter):
     pass
     pass
 
 
 ```
 ```

+ 17 - 0
docs/release-notes/version-4.4.md

@@ -1,5 +1,22 @@
 # NetBox v4.4
 # NetBox v4.4
 
 
+## v4.4.8 (2025-12-09)
+
+### Enhancements
+
+* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
+* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
+
+### Bug Fixes
+
+* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
+* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
+* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
+* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
+* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
+
+---
+
 ## v4.4.7 (2025-11-25)
 ## v4.4.7 (2025-11-25)
 
 
 ### Enhancements
 ### Enhancements

+ 6 - 5
netbox/circuits/graphql/filter_mixins.py

@@ -3,17 +3,18 @@ from typing import Annotated, TYPE_CHECKING
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-
-from netbox.graphql.filter_mixins import OrganizationalModelFilterMixin
+from strawberry_django import BaseFilterLookup
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from netbox.graphql.enums import ColorEnum
     from netbox.graphql.enums import ColorEnum
 
 
 __all__ = (
 __all__ = (
-    'BaseCircuitTypeFilterMixin',
+    'CircuitTypeFilterMixin',
 )
 )
 
 
 
 
 @dataclass
 @dataclass
-class BaseCircuitTypeFilterMixin(OrganizationalModelFilterMixin):
-    color: Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')] | None = strawberry_django.filter_field()
+class CircuitTypeFilterMixin:
+    color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
+        strawberry_django.filter_field()
+    )

+ 14 - 24
netbox/circuits/graphql/filters.py

@@ -7,17 +7,12 @@ from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
 
 
 from circuits import models
 from circuits import models
-from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
+from circuits.graphql.filter_mixins import CircuitTypeFilterMixin
 from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
 from dcim.graphql.filter_mixins import CabledObjectModelFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
-from netbox.graphql.filter_mixins import (
-    DistanceFilterMixin,
-    ImageAttachmentFilterMixin,
-    OrganizationalModelFilterMixin,
-    PrimaryModelFilterMixin,
-)
+from netbox.graphql.filter_mixins import DistanceFilterMixin, ImageAttachmentFilterMixin
+from netbox.graphql.filters import ChangeLoggedModelFilter, OrganizationalModelFilter, PrimaryModelFilter
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
-from .filter_mixins import BaseCircuitTypeFilterMixin
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
@@ -43,10 +38,9 @@ __all__ = (
 
 
 @strawberry_django.filter_type(models.CircuitTermination, lookups=True)
 @strawberry_django.filter_type(models.CircuitTermination, lookups=True)
 class CircuitTerminationFilter(
 class CircuitTerminationFilter(
-    BaseObjectTypeFilterMixin,
     CustomFieldsFilterMixin,
     CustomFieldsFilterMixin,
     TagsFilterMixin,
     TagsFilterMixin,
-    ChangeLogFilterMixin,
+    ChangeLoggedModelFilter,
     CabledObjectModelFilterMixin,
     CabledObjectModelFilterMixin,
 ):
 ):
     circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     circuit: Annotated['CircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -95,7 +89,7 @@ class CircuitFilter(
     ImageAttachmentFilterMixin,
     ImageAttachmentFilterMixin,
     DistanceFilterMixin,
     DistanceFilterMixin,
     TenancyFilterMixin,
     TenancyFilterMixin,
-    PrimaryModelFilterMixin
+    PrimaryModelFilter
 ):
 ):
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
@@ -124,19 +118,17 @@ class CircuitFilter(
 
 
 
 
 @strawberry_django.filter_type(models.CircuitType, lookups=True)
 @strawberry_django.filter_type(models.CircuitType, lookups=True)
-class CircuitTypeFilter(BaseCircuitTypeFilterMixin):
+class CircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.CircuitGroup, lookups=True)
 @strawberry_django.filter_type(models.CircuitGroup, lookups=True)
-class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
+class CircuitGroupFilter(TenancyFilterMixin, OrganizationalModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
 @strawberry_django.filter_type(models.CircuitGroupAssignment, lookups=True)
-class CircuitGroupAssignmentFilter(
-    BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
-):
+class CircuitGroupAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     member_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -151,7 +143,7 @@ class CircuitGroupAssignmentFilter(
 
 
 
 
 @strawberry_django.filter_type(models.Provider, lookups=True)
 @strawberry_django.filter_type(models.Provider, lookups=True)
-class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+class ProviderFilter(ContactFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     asns: Annotated['ASNFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -161,7 +153,7 @@ class ProviderFilter(ContactFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ProviderAccount, lookups=True)
 @strawberry_django.filter_type(models.ProviderAccount, lookups=True)
-class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
+class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilter):
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -171,7 +163,7 @@ class ProviderAccountFilter(ContactFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
 @strawberry_django.filter_type(models.ProviderNetwork, lookups=True)
-class ProviderNetworkFilter(PrimaryModelFilterMixin):
+class ProviderNetworkFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider: Annotated['ProviderFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -181,12 +173,12 @@ class ProviderNetworkFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
 @strawberry_django.filter_type(models.VirtualCircuitType, lookups=True)
-class VirtualCircuitTypeFilter(BaseCircuitTypeFilterMixin):
+class VirtualCircuitTypeFilter(CircuitTypeFilterMixin, OrganizationalModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
 @strawberry_django.filter_type(models.VirtualCircuit, lookups=True)
-class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilter):
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     cid: FilterLookup[str] | None = strawberry_django.filter_field()
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     provider_network: Annotated['ProviderNetworkFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -209,9 +201,7 @@ class VirtualCircuitFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
 @strawberry_django.filter_type(models.VirtualCircuitTermination, lookups=True)
-class VirtualCircuitTerminationFilter(
-    BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin
-):
+class VirtualCircuitTerminationFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
     virtual_circuit: Annotated['VirtualCircuitFilter', strawberry.lazy('circuits.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 3 - 16
netbox/core/graphql/filter_mixins.py

@@ -4,31 +4,18 @@ from typing import Annotated, TYPE_CHECKING
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from strawberry import ID
-from strawberry_django import FilterLookup, DatetimeFilterLookup
+from strawberry_django import DatetimeFilterLookup
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .filters import *
     from .filters import *
 
 
 __all__ = (
 __all__ = (
-    'BaseFilterMixin',
-    'BaseObjectTypeFilterMixin',
-    'ChangeLogFilterMixin',
+    'ChangeLoggingMixin',
 )
 )
 
 
 
 
-# @strawberry.input
-class BaseFilterMixin: ...
-
-
-@dataclass
-class BaseObjectTypeFilterMixin(BaseFilterMixin):
-    id: FilterLookup[ID] | None = strawberry_django.filter_field()
-
-
 @dataclass
 @dataclass
-class ChangeLogFilterMixin(BaseFilterMixin):
-    id: FilterLookup[ID] | None = strawberry_django.filter_field()
+class ChangeLoggingMixin:
     # TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange
     # TODO: "changelog" is not a valid field name; needs to be updated for ObjectChange
     changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     changelog: Annotated['ObjectChangeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()

+ 5 - 9
netbox/core/graphql/filters.py

@@ -8,8 +8,7 @@ from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
 from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup
 
 
 from core import models
 from core import models
-from core.graphql.filter_mixins import BaseFilterMixin
-from netbox.graphql.filter_mixins import PrimaryModelFilterMixin
+from netbox.graphql.filters import BaseModelFilter, PrimaryModelFilter
 from .enums import *
 from .enums import *
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -25,8 +24,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.DataFile, lookups=True)
 @strawberry_django.filter_type(models.DataFile, lookups=True)
-class DataFileFilter(BaseFilterMixin):
-    id: FilterLookup[ID] | None = strawberry_django.filter_field()
+class DataFileFilter(BaseModelFilter):
     created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     last_updated: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
     source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
@@ -41,7 +39,7 @@ class DataFileFilter(BaseFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.DataSource, lookups=True)
 @strawberry_django.filter_type(models.DataSource, lookups=True)
-class DataSourceFilter(PrimaryModelFilterMixin):
+class DataSourceFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     type: FilterLookup[str] | None = strawberry_django.filter_field()
     type: FilterLookup[str] | None = strawberry_django.filter_field()
     source_url: FilterLookup[str] | None = strawberry_django.filter_field()
     source_url: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -60,8 +58,7 @@ class DataSourceFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ObjectChange, lookups=True)
 @strawberry_django.filter_type(models.ObjectChange, lookups=True)
-class ObjectChangeFilter(BaseFilterMixin):
-    id: FilterLookup[ID] | None = strawberry_django.filter_field()
+class ObjectChangeFilter(BaseModelFilter):
     time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     time: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user_name: FilterLookup[str] | None = strawberry_django.filter_field()
     user_name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -88,7 +85,6 @@ class ObjectChangeFilter(BaseFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(DjangoContentType, lookups=True)
 @strawberry_django.filter_type(DjangoContentType, lookups=True)
-class ContentTypeFilter(BaseFilterMixin):
-    id: FilterLookup[ID] | None = strawberry_django.filter_field()
+class ContentTypeFilter(BaseModelFilter):
     app_label: FilterLookup[str] | None = strawberry_django.filter_field()
     app_label: FilterLookup[str] | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()
     model: FilterLookup[str] | None = strawberry_django.filter_field()

+ 52 - 0
netbox/dcim/api/serializers_/base.py

@@ -2,10 +2,12 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 from rest_framework import serializers
 
 
+from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
 from utilities.api import get_serializer_for_model
 from utilities.api import get_serializer_for_model
 
 
 __all__ = (
 __all__ = (
     'ConnectedEndpointsSerializer',
     'ConnectedEndpointsSerializer',
+    'PortSerializer',
 )
 )
 
 
 
 
@@ -35,3 +37,53 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
     @extend_schema_field(serializers.BooleanField)
     @extend_schema_field(serializers.BooleanField)
     def get_connected_endpoints_reachable(self, obj):
     def get_connected_endpoints_reachable(self, obj):
         return obj._path and obj._path.is_complete and obj._path.is_active
         return obj._path and obj._path.is_complete and obj._path.is_active
+
+
+class PortSerializer(serializers.ModelSerializer):
+    """
+    Base serializer for front & rear port and port templates.
+    """
+    @property
+    def _mapper(self):
+        """
+        Return the model and ForeignKey field name used to track port mappings for this model.
+        """
+        if self.Meta.model is FrontPort:
+            return PortMapping, 'front_port'
+        if self.Meta.model is RearPort:
+            return PortMapping, 'rear_port'
+        if self.Meta.model is FrontPortTemplate:
+            return PortTemplateMapping, 'front_port'
+        if self.Meta.model is RearPortTemplate:
+            return PortTemplateMapping, 'rear_port'
+        raise ValueError(f"Could not determine mapping details for {self.__class__}")
+
+    def create(self, validated_data):
+        mappings = validated_data.pop('mappings', [])
+        instance = super().create(validated_data)
+
+        # Create port mappings
+        mapping_model, fk_name = self._mapper
+        for attrs in mappings:
+            mapping_model.objects.create(**{
+                fk_name: instance,
+                **attrs,
+            })
+
+        return instance
+
+    def update(self, instance, validated_data):
+        mappings = validated_data.pop('mappings', None)
+        instance = super().update(instance, validated_data)
+
+        if mappings is not None:
+            # Update port mappings
+            mapping_model, fk_name = self._mapper
+            mapping_model.objects.filter(**{fk_name: instance}).delete()
+            for attrs in mappings:
+                mapping_model.objects.create(**{
+                    fk_name: instance,
+                    **attrs,
+                })
+
+        return instance

+ 43 - 18
netbox/dcim/api/serializers_/device_components.py

@@ -5,21 +5,21 @@ from rest_framework import serializers
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
-    RearPort, VirtualDeviceContext,
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
+    PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
 )
 )
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.api.serializers_.vrfs import VRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.gfk_fields import GFKSerializerField
-from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
+from netbox.api.serializers import NetBoxModelSerializer
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
 from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
 from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
 from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
 from wireless.choices import *
 from wireless.choices import *
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
-from .base import ConnectedEndpointsSerializer
+from .base import ConnectedEndpointsSerializer, PortSerializer
 from .cables import CabledObjectSerializer
 from .cables import CabledObjectSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
@@ -294,7 +294,20 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
         return super().validate(data)
         return super().validate(data)
 
 
 
 
-class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class RearPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPort.objects.all(),
+    )
+
+    class Meta:
+        model = PortMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
     module = ModuleSerializer(
         nested=True,
         nested=True,
@@ -303,28 +316,36 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
         allow_null=True
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
             'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
-            'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
-            'custom_fields', 'created', 'last_updated', '_occupied',
+            'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 
 
-class FrontPortRearPortSerializer(WritableNestedSerializer):
-    """
-    NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
-    """
+class FrontPortMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPort.objects.all(),
+    )
 
 
     class Meta:
     class Meta:
-        model = RearPort
-        fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
+        model = PortMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
 
 
 
 
-class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
     device = DeviceSerializer(nested=True)
     device = DeviceSerializer(nested=True)
     module = ModuleSerializer(
     module = ModuleSerializer(
         nested=True,
         nested=True,
@@ -333,14 +354,18 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
         allow_null=True
         allow_null=True
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = FrontPortRearPortSerializer()
+    rear_ports = FrontPortMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
-            'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
+            'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+            'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
         brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 

+ 45 - 8
netbox/dcim/api/serializers_/devicetype_components.py

@@ -5,12 +5,14 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
-    InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
+    RearPortTemplate,
 )
 )
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 from wireless.choices import *
 from wireless.choices import *
+from .base import PortSerializer
 from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
 from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
 from .nested import NestedInterfaceTemplateSerializer
 from .nested import NestedInterfaceTemplateSerializer
@@ -205,7 +207,20 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
-class RearPortTemplateSerializer(ComponentTemplateSerializer):
+class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='rear_port_position'
+    )
+    front_port = serializers.PrimaryKeyRelatedField(
+        queryset=FrontPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'front_port', 'front_port_position')
+
+
+class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
     device_type = DeviceTypeSerializer(
         required=False,
         required=False,
         nested=True,
         nested=True,
@@ -219,17 +234,35 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
         default=None
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
+    front_ports = RearPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
-            'positions', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+            'front_ports', 'description', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 
 
-class FrontPortTemplateSerializer(ComponentTemplateSerializer):
+class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
+    position = serializers.IntegerField(
+        source='front_port_position'
+    )
+    rear_port = serializers.PrimaryKeyRelatedField(
+        queryset=RearPortTemplate.objects.all(),
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = ('position', 'rear_port', 'rear_port_position')
+
+
+class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
     device_type = DeviceTypeSerializer(
     device_type = DeviceTypeSerializer(
         nested=True,
         nested=True,
         required=False,
         required=False,
@@ -243,13 +276,17 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer):
         default=None
         default=None
     )
     )
     type = ChoiceField(choices=PortTypeChoices)
     type = ChoiceField(choices=PortTypeChoices)
-    rear_port = RearPortTemplateSerializer(nested=True)
+    rear_ports = FrontPortTemplateMappingSerializer(
+        source='mappings',
+        many=True,
+        required=False,
+    )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
-            'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
+            'rear_ports', 'description', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 

+ 2 - 2
netbox/dcim/constants.py

@@ -32,8 +32,8 @@ CABLE_POSITION_MAX = 1024
 # RearPorts
 # RearPorts
 #
 #
 
 
-REARPORT_POSITIONS_MIN = 1
-REARPORT_POSITIONS_MAX = 1024
+PORT_POSITION_MIN = 1
+PORT_POSITION_MAX = 1024
 
 
 
 
 #
 #

+ 33 - 4
netbox/dcim/filtersets.py

@@ -904,12 +904,15 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         null_value=None
         null_value=None
     )
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPort.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPort.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
-        fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
+        fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
 
 
 @register_filterset
 @register_filterset
@@ -918,6 +921,12 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
@@ -1664,6 +1673,17 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         choices=DeviceStatusChoices,
         choices=DeviceStatusChoices,
         field_name='device__status',
         field_name='device__status',
     )
     )
+    tenant_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__tenant',
+        queryset=Tenant.objects.all(),
+        label=_('Tenant (ID)'),
+    )
+    tenant = django_filters.ModelMultipleChoiceFilter(
+        field_name='device__tenant__slug',
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label=_('Tenant (slug)'),
+    )
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -2137,13 +2157,16 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
         null_value=None
         null_value=None
     )
     )
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
     rear_port_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=RearPort.objects.all()
+        field_name='mappings__rear_port',
+        queryset=RearPort.objects.all(),
+        to_field_name='rear_port',
+        label=_('Rear port (ID)'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
-            'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
+            'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
             'cable_position',
             'cable_position',
         )
         )
 
 
@@ -2154,6 +2177,12 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
     )
     )
+    front_port_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='mappings__front_port',
+        queryset=FrontPort.objects.all(),
+        to_field_name='front_port',
+        label=_('Front port (ID)'),
+    )
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort

+ 18 - 31
netbox/dcim/forms/bulk_import.py

@@ -476,14 +476,30 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
         required=False,
         required=False,
         help_text=_('Unit for module weight')
         help_text=_('Unit for module weight')
     )
     )
+    attribute_data = forms.JSONField(
+        label=_('Attributes'),
+        required=False,
+        help_text=_('Attribute values for the assigned profile, passed as a dictionary')
+    )
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
             'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
-            'owner', 'comments', 'tags'
+            'attribute_data', 'owner', 'comments', 'tags',
         ]
         ]
 
 
+    def clean(self):
+        super().clean()
+
+        # Attribute data may be included only if a profile is specified
+        if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
+            raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
+
+        # Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
+        if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
+            self.cleaned_data['attribute_data'] = {}
+
 
 
 class DeviceRoleImportForm(NestedGroupModelImportForm):
 class DeviceRoleImportForm(NestedGroupModelImportForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
@@ -1075,12 +1091,6 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
     )
     )
-    rear_port = CSVModelChoiceField(
-        label=_('Rear port'),
-        queryset=RearPort.objects.all(),
-        to_field_name='name',
-        help_text=_('Corresponding rear port')
-    )
     type = CSVChoiceField(
     type = CSVChoiceField(
         label=_('Type'),
         label=_('Type'),
         choices=PortTypeChoices,
         choices=PortTypeChoices,
@@ -1090,32 +1100,9 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
-            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
-            'description', 'owner', 'tags'
+            'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags'
         )
         )
 
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit RearPort choices to those belonging to this device (or VC master)
-        if self.is_bound and 'device' in self.data:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['rear_port'].queryset = RearPort.objects.filter(
-                device__in=[device, device.get_vc_master()]
-            )
-        else:
-            self.fields['rear_port'].queryset = RearPort.objects.none()
-
 
 
 class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
 class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(

+ 22 - 11
netbox/dcim/forms/filtersets.py

@@ -13,6 +13,7 @@ from netbox.forms import (
     PrimaryModelFilterSetForm,
     PrimaryModelFilterSetForm,
 )
 )
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
 from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
+from tenancy.models import Tenant
 from users.models import Owner, User
 from users.models import Owner, User
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
 from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
@@ -123,6 +124,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
         required=False,
         required=False,
         label=_('Device role')
         label=_('Device role')
     )
     )
+    tenant_id = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        label=_('Tenant')
+    )
     device_id = DynamicModelMultipleChoiceField(
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
@@ -131,7 +137,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
             'location_id': '$location_id',
             'location_id': '$location_id',
             'virtual_chassis_id': '$virtual_chassis_id',
             'virtual_chassis_id': '$virtual_chassis_id',
             'device_type_id': '$device_type_id',
             'device_type_id': '$device_type_id',
-            'role_id': '$role_id'
+            'role_id': '$role_id',
+            'tenant_id': '$tenant_id'
         },
         },
         label=_('Device')
         label=_('Device')
     )
     )
@@ -1360,7 +1367,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     )
@@ -1384,7 +1392,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1409,7 +1417,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('name', 'label', 'type', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
     )
     )
@@ -1428,7 +1437,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1461,7 +1470,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'vdc_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
         FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
@@ -1582,7 +1592,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            name=_('Device')
         ),
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
         FieldSet('cabled', 'occupied', name=_('Cable')),
     )
     )
@@ -1606,7 +1617,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
         FieldSet('cabled', 'occupied', name=_('Cable')),
         FieldSet('cabled', 'occupied', name=_('Cable')),
@@ -1630,7 +1641,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('name', 'label', 'position', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
     )
     )
@@ -1648,7 +1659,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('name', 'label', name=_('Attributes')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
     )
     )
@@ -1665,7 +1676,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
         ),
         ),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
         FieldSet(
         FieldSet(
-            'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
+            'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
             name=_('Device')
             name=_('Device')
         ),
         ),
     )
     )

+ 76 - 1
netbox/dcim/forms/mixins.py

@@ -1,10 +1,12 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.db import connection
+from django.db.models.signals import post_save
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.constants import LOCATION_SCOPE_TYPES
-from dcim.models import Site
+from dcim.models import PortMapping, PortTemplateMapping, Site
 from utilities.forms import get_field_value
 from utilities.forms import get_field_value
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
     ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
@@ -13,6 +15,7 @@ from utilities.templatetags.builtins.filters import bettertitle
 from utilities.forms.widgets import HTMXSelect
 from utilities.forms.widgets import HTMXSelect
 
 
 __all__ = (
 __all__ = (
+    'FrontPortFormMixin',
     'ScopedBulkEditForm',
     'ScopedBulkEditForm',
     'ScopedForm',
     'ScopedForm',
     'ScopedImportForm',
     'ScopedImportForm',
@@ -128,3 +131,75 @@ class ScopedImportForm(forms.Form):
                     "Please select a {scope_type}."
                     "Please select a {scope_type}."
                 ).format(scope_type=scope_type.model_class()._meta.model_name)
                 ).format(scope_type=scope_type.model_class()._meta.model_name)
             })
             })
+
+
+class FrontPortFormMixin(forms.Form):
+    rear_ports = forms.MultipleChoiceField(
+        choices=[],
+        label=_('Rear ports'),
+        widget=forms.SelectMultiple(attrs={'size': 8})
+    )
+
+    port_mapping_model = PortMapping
+    parent_field = 'device'
+
+    def clean(self):
+        super().clean()
+
+        # Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
+        # mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
+        # FrontPorts being creation by the number of positions.
+        positions = self.cleaned_data['positions']
+        frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
+        rearport_count = len(self.cleaned_data['rear_ports'])
+        if frontport_count * positions != rearport_count:
+            raise forms.ValidationError({
+                'rear_ports': _(
+                    "The total number of front port positions ({frontport_count}) must match the selected number of "
+                    "rear port positions ({rearport_count})."
+                ).format(
+                    frontport_count=frontport_count,
+                    rearport_count=rearport_count
+                )
+            })
+
+    def _save_m2m(self):
+        super()._save_m2m()
+
+        # TODO: Can this be made more efficient?
+        # Delete existing rear port mappings
+        self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
+
+        # Create new rear port mappings
+        mappings = []
+        if self.port_mapping_model is PortTemplateMapping:
+            params = {
+                'device_type_id': self.instance.device_type_id,
+                'module_type_id': self.instance.module_type_id,
+            }
+        else:
+            params = {
+                'device_id': self.instance.device_id,
+            }
+        for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
+            rear_port_id, rear_port_position = rp_position.split(':')
+            mappings.append(
+                self.port_mapping_model(**{
+                    **params,
+                    'front_port_id': self.instance.pk,
+                    'front_port_position': i,
+                    'rear_port_id': rear_port_id,
+                    'rear_port_position': rear_port_position,
+                })
+            )
+        self.port_mapping_model.objects.bulk_create(mappings)
+        # Send post_save signals
+        for mapping in mappings:
+            post_save.send(
+                sender=PortMapping,
+                instance=mapping,
+                created=True,
+                raw=False,
+                using=connection,
+                update_fields=None
+            )

+ 90 - 25
netbox/dcim/forms/model_forms.py

@@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.forms.mixins import FrontPortFormMixin
 from dcim.models import *
 from dcim.models import *
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
@@ -1112,34 +1113,66 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
         ]
         ]
 
 
 
 
-class FrontPortTemplateForm(ModularComponentTemplateForm):
-    rear_port = DynamicModelChoiceField(
-        label=_('Rear port'),
-        queryset=RearPortTemplate.objects.all(),
-        required=False,
-        query_params={
-            'device_type_id': '$device_type',
-            'module_type_id': '$module_type',
-        }
-    )
-
+class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
             ),
-            'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'name', 'label', 'type', 'positions', 'rear_ports', 'description',
         ),
         ),
     )
     )
 
 
+    # Override FrontPortFormMixin attrs
+    port_mapping_model = PortTemplateMapping
+    parent_field = 'device_type'
+
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
-            'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
+            device_type = DeviceType.objects.get(pk=device_type_id)
+        else:
+            return
+
+        # Populate rear port choices
+        self.fields['rear_ports'].choices = self._get_rear_port_choices(device_type, self.instance)
+
+        # Set initial rear port mappings
+        if self.instance.pk:
+            self.initial['rear_ports'] = [
+                f'{mapping.rear_port_id}:{mapping.rear_port_position}'
+                for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
+            ]
+
+    def _get_rear_port_choices(self, device_type, front_port):
+        """
+        Return a list of choices representing each available rear port & position pair on the device type, excluding
+        those assigned to the specified instance.
+        """
+        occupied_rear_port_positions = [
+            f'{mapping.rear_port_id}:{mapping.rear_port_position}'
+            for mapping in device_type.port_mappings.exclude(front_port=front_port.pk)
         ]
         ]
 
 
+        choices = []
+        for rear_port in RearPortTemplate.objects.filter(device_type=device_type):
+            for i in range(1, rear_port.positions + 1):
+                pair_id = f'{rear_port.pk}:{i}'
+                if pair_id not in occupied_rear_port_positions:
+                    pair_label = f'{rear_port.name}:{i}'
+                    choices.append(
+                        (pair_id, pair_label)
+                    )
+        return choices
+
 
 
 class RearPortTemplateForm(ModularComponentTemplateForm):
 class RearPortTemplateForm(ModularComponentTemplateForm):
     fieldsets = (
     fieldsets = (
@@ -1578,17 +1611,10 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
         }
         }
 
 
 
 
-class FrontPortForm(ModularDeviceComponentForm):
-    rear_port = DynamicModelChoiceField(
-        queryset=RearPort.objects.all(),
-        query_params={
-            'device_id': '$device',
-        }
-    )
-
+class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
+            'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
             'description', 'tags',
             'description', 'tags',
         ),
         ),
     )
     )
@@ -1596,10 +1622,49 @@ class FrontPortForm(ModularDeviceComponentForm):
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
         fields = [
         fields = [
-            'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
-            'description', 'owner', 'tags',
+            'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner',
+            'tags',
         ]
         ]
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if device_id := self.data.get('device') or self.initial.get('device'):
+            device = Device.objects.get(pk=device_id)
+        else:
+            return
+
+        # Populate rear port choices
+        self.fields['rear_ports'].choices = self._get_rear_port_choices(device, self.instance)
+
+        # Set initial rear port mappings
+        if self.instance.pk:
+            self.initial['rear_ports'] = [
+                f'{mapping.rear_port_id}:{mapping.rear_port_position}'
+                for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
+            ]
+
+    def _get_rear_port_choices(self, device, front_port):
+        """
+        Return a list of choices representing each available rear port & position pair on the device, excluding those
+        assigned to the specified instance.
+        """
+        occupied_rear_port_positions = [
+            f'{mapping.rear_port_id}:{mapping.rear_port_position}'
+            for mapping in device.port_mappings.exclude(front_port=front_port.pk)
+        ]
+
+        choices = []
+        for rear_port in RearPort.objects.filter(device=device):
+            for i in range(1, rear_port.positions + 1):
+                pair_id = f'{rear_port.pk}:{i}'
+                if pair_id not in occupied_rear_port_positions:
+                    pair_label = f'{rear_port.name}:{i}'
+                    choices.append(
+                        (pair_id, pair_label)
+                    )
+        return choices
+
 
 
 class RearPortForm(ModularDeviceComponentForm):
 class RearPortForm(ModularDeviceComponentForm):
     fieldsets = (
     fieldsets = (

+ 19 - 122
netbox/dcim/forms/object_create.py

@@ -109,85 +109,30 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp
 
 
 
 
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
-    rear_port = forms.MultipleChoiceField(
-        choices=[],
-        label=_('Rear ports'),
-        help_text=_('Select one rear port assignment for each front port being created.'),
-        widget=forms.SelectMultiple(attrs={'size': 6})
-    )
 
 
-    # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
+    # Override fieldsets from FrontPortTemplateForm
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
             TabbedGroups(
             TabbedGroups(
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
             ),
-            'name', 'label', 'type', 'color', 'rear_port', 'description',
+            'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description',
         ),
         ),
     )
     )
 
 
-    class Meta(model_forms.FrontPortTemplateForm.Meta):
-        exclude = ('name', 'label', 'rear_port', 'rear_port_position')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # TODO: This needs better validation
-        if 'device_type' in self.initial or self.data.get('device_type'):
-            parent = DeviceType.objects.get(
-                pk=self.initial.get('device_type') or self.data.get('device_type')
-            )
-        elif 'module_type' in self.initial or self.data.get('module_type'):
-            parent = ModuleType.objects.get(
-                pk=self.initial.get('module_type') or self.data.get('module_type')
-            )
-        else:
-            return
-
-        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
-        occupied_port_positions = [
-            (front_port.rear_port_id, front_port.rear_port_position)
-            for front_port in parent.frontporttemplates.all()
-        ]
-
-        # Populate rear port choices
-        choices = []
-        rear_ports = parent.rearporttemplates.all()
-        for rear_port in rear_ports:
-            for i in range(1, rear_port.positions + 1):
-                if (rear_port.pk, i) not in occupied_port_positions:
-                    choices.append(
-                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
-                    )
-        self.fields['rear_port'].choices = choices
-
-    def clean(self):
-        super().clean()
-
-        # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
-        # positions
-        frontport_count = len(self.cleaned_data['name'])
-        rearport_count = len(self.cleaned_data['rear_port'])
-        if frontport_count != rearport_count:
-            raise forms.ValidationError({
-                'rear_port': _(
-                    "The number of front port templates to be created ({frontport_count}) must match the selected "
-                    "number of rear port positions ({rearport_count})."
-                ).format(
-                    frontport_count=frontport_count,
-                    rearport_count=rearport_count
-                )
-            })
+    class Meta:
+        model = FrontPortTemplate
+        fields = (
+            'device_type', 'module_type', 'type', 'color', 'positions', 'description',
+        )
 
 
     def get_iterative_data(self, iteration):
     def get_iterative_data(self, iteration):
-
-        # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
+        positions = self.cleaned_data['positions']
+        offset = positions * iteration
 
 
         return {
         return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
+            'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
         }
         }
 
 
 
 
@@ -269,74 +214,26 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
             }
             }
         )
         )
     )
     )
-    rear_port = forms.MultipleChoiceField(
-        choices=[],
-        label=_('Rear ports'),
-        help_text=_('Select one rear port assignment for each front port being created.'),
-        widget=forms.SelectMultiple(attrs={'size': 6})
-    )
 
 
     # Override fieldsets from FrontPortForm to omit rear_port_position
     # Override fieldsets from FrontPortForm to omit rear_port_position
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
+            'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
+            'description', 'tags',
         ),
         ),
     )
     )
 
 
-    class Meta(model_forms.FrontPortForm.Meta):
-        exclude = ('name', 'label', 'rear_port', 'rear_port_position')
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        if device_id := self.data.get('device') or self.initial.get('device'):
-            device = Device.objects.get(pk=device_id)
-        else:
-            return
-
-        # Determine which rear port positions are occupied. These will be excluded from the list of available
-        # mappings.
-        occupied_port_positions = [
-            (front_port.rear_port_id, front_port.rear_port_position)
-            for front_port in device.frontports.all()
+    class Meta:
+        model = FrontPort
+        fields = [
+            'device', 'module', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'tags',
         ]
         ]
 
 
-        # Populate rear port choices
-        choices = []
-        rear_ports = RearPort.objects.filter(device=device)
-        for rear_port in rear_ports:
-            for i in range(1, rear_port.positions + 1):
-                if (rear_port.pk, i) not in occupied_port_positions:
-                    choices.append(
-                        ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
-                    )
-        self.fields['rear_port'].choices = choices
-
-    def clean(self):
-        super().clean()
-
-        # Check that the number of FrontPorts to be created matches the selected number of RearPort positions
-        frontport_count = len(self.cleaned_data['name'])
-        rearport_count = len(self.cleaned_data['rear_port'])
-        if frontport_count != rearport_count:
-            raise forms.ValidationError({
-                'rear_port': _(
-                    "The number of front ports to be created ({frontport_count}) must match the selected number of "
-                    "rear port positions ({rearport_count})."
-                ).format(
-                    frontport_count=frontport_count,
-                    rearport_count=rearport_count
-                )
-            })
-
     def get_iterative_data(self, iteration):
     def get_iterative_data(self, iteration):
-
-        # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
-
+        positions = self.cleaned_data['positions']
+        offset = positions * iteration
         return {
         return {
-            'rear_port': int(rear_port),
-            'rear_port_position': int(position),
+            'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
         }
         }
 
 
 
 

+ 21 - 21
netbox/dcim/forms/object_import.py

@@ -13,6 +13,7 @@ __all__ = (
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InventoryItemTemplateImportForm',
     'InventoryItemTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleBayTemplateImportForm',
+    'PortTemplateMappingImportForm',
     'PowerOutletTemplateImportForm',
     'PowerOutletTemplateImportForm',
     'PowerPortTemplateImportForm',
     'PowerPortTemplateImportForm',
     'RearPortTemplateImportForm',
     'RearPortTemplateImportForm',
@@ -113,31 +114,11 @@ class FrontPortTemplateImportForm(forms.ModelForm):
         label=_('Type'),
         label=_('Type'),
         choices=PortTypeChoices.CHOICES
         choices=PortTypeChoices.CHOICES
     )
     )
-    rear_port = forms.ModelChoiceField(
-        label=_('Rear port'),
-        queryset=RearPortTemplate.objects.all(),
-        to_field_name='name'
-    )
-
-    def clean_device_type(self):
-        if device_type := self.cleaned_data['device_type']:
-            rear_port = self.fields['rear_port']
-            rear_port.queryset = rear_port.queryset.filter(device_type=device_type)
-
-        return device_type
-
-    def clean_module_type(self):
-        if module_type := self.cleaned_data['module_type']:
-            rear_port = self.fields['rear_port']
-            rear_port.queryset = rear_port.queryset.filter(module_type=module_type)
-
-        return module_type
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label',
-            'description',
+            'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
         ]
         ]
 
 
 
 
@@ -154,6 +135,25 @@ class RearPortTemplateImportForm(forms.ModelForm):
         ]
         ]
 
 
 
 
+class PortTemplateMappingImportForm(forms.ModelForm):
+    front_port = forms.ModelChoiceField(
+        label=_('Front port'),
+        queryset=FrontPortTemplate.objects.all(),
+        to_field_name='name',
+    )
+    rear_port = forms.ModelChoiceField(
+        label=_('Rear port'),
+        queryset=RearPortTemplate.objects.all(),
+        to_field_name='name',
+    )
+
+    class Meta:
+        model = PortTemplateMapping
+        fields = [
+            'front_port', 'front_port_position', 'rear_port', 'rear_port_position',
+        ]
+
+
 class ModuleBayTemplateImportForm(forms.ModelForm):
 class ModuleBayTemplateImportForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:

+ 10 - 12
netbox/dcim/graphql/filter_mixins.py

@@ -6,9 +6,7 @@ import strawberry_django
 from strawberry import ID
 from strawberry import ID
 from strawberry_django import BaseFilterLookup, FilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup
 
 
-from core.graphql.filter_mixins import BaseFilterMixin, ChangeLogFilterMixin
 from core.graphql.filters import ContentTypeFilter
 from core.graphql.filters import ContentTypeFilter
-from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin
 from .enums import *
 from .enums import *
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
@@ -22,16 +20,16 @@ __all__ = (
     'ComponentModelFilterMixin',
     'ComponentModelFilterMixin',
     'ComponentTemplateFilterMixin',
     'ComponentTemplateFilterMixin',
     'InterfaceBaseFilterMixin',
     'InterfaceBaseFilterMixin',
-    'ModularComponentModelFilterMixin',
+    'ModularComponentFilterMixin',
     'ModularComponentTemplateFilterMixin',
     'ModularComponentTemplateFilterMixin',
-    'RackBaseFilterMixin',
+    'RackFilterMixin',
     'RenderConfigFilterMixin',
     'RenderConfigFilterMixin',
     'ScopedFilterMixin',
     'ScopedFilterMixin',
 )
 )
 
 
 
 
 @dataclass
 @dataclass
-class ScopedFilterMixin(BaseFilterMixin):
+class ScopedFilterMixin:
     scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     scope_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -39,7 +37,7 @@ class ScopedFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class ComponentModelFilterMixin(NetBoxModelFilterMixin):
+class ComponentModelFilterMixin:
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -48,7 +46,7 @@ class ComponentModelFilterMixin(NetBoxModelFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class ModularComponentModelFilterMixin(ComponentModelFilterMixin):
+class ModularComponentFilterMixin(ComponentModelFilterMixin):
     module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     module: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     module_id: ID | None = strawberry_django.filter_field()
     module_id: ID | None = strawberry_django.filter_field()
     inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     inventory_items: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -57,7 +55,7 @@ class ModularComponentModelFilterMixin(ComponentModelFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class CabledObjectModelFilterMixin(BaseFilterMixin):
+class CabledObjectModelFilterMixin:
     cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     cable_id: ID | None = strawberry_django.filter_field()
     cable_id: ID | None = strawberry_django.filter_field()
     cable_end: (
     cable_end: (
@@ -67,7 +65,7 @@ class CabledObjectModelFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class ComponentTemplateFilterMixin(ChangeLogFilterMixin):
+class ComponentTemplateFilterMixin:
     device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -85,7 +83,7 @@ class ModularComponentTemplateFilterMixin(ComponentTemplateFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class RenderConfigFilterMixin(BaseFilterMixin):
+class RenderConfigFilterMixin:
     config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     config_template: Annotated['ConfigTemplateFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -93,7 +91,7 @@ class RenderConfigFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class InterfaceBaseFilterMixin(BaseFilterMixin):
+class InterfaceBaseFilterMixin:
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     mtu: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -124,7 +122,7 @@ class InterfaceBaseFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class RackBaseFilterMixin(WeightFilterMixin, PrimaryModelFilterMixin):
+class RackFilterMixin:
     width: BaseFilterLookup[Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     width: BaseFilterLookup[Annotated['RackWidthEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 92 - 65
netbox/dcim/graphql/filters.py

@@ -6,29 +6,25 @@ import strawberry_django
 from strawberry.scalars import ID
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
 from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, FilterLookup
 
 
-from core.graphql.filter_mixins import ChangeLogFilterMixin
 from dcim import models
 from dcim import models
 from dcim.constants import *
 from dcim.constants import *
 from dcim.graphql.enums import InterfaceKindEnum
 from dcim.graphql.enums import InterfaceKindEnum
+from dcim.graphql.filter_mixins import (
+    ComponentModelFilterMixin, ComponentTemplateFilterMixin, ModularComponentFilterMixin,
+    ModularComponentTemplateFilterMixin, RackFilterMixin,
+)
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
 from extras.graphql.filter_mixins import ConfigContextFilterMixin
-from netbox.graphql.filter_mixins import (
-    PrimaryModelFilterMixin,
-    OrganizationalModelFilterMixin,
-    NestedGroupModelFilterMixin,
-    ImageAttachmentFilterMixin,
-    WeightFilterMixin,
+from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
+from netbox.graphql.filters import (
+    BaseModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
+    NetBoxModelFilter,
 )
 )
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
 from .filter_mixins import (
 from .filter_mixins import (
     CabledObjectModelFilterMixin,
     CabledObjectModelFilterMixin,
-    ComponentModelFilterMixin,
-    ComponentTemplateFilterMixin,
     InterfaceBaseFilterMixin,
     InterfaceBaseFilterMixin,
-    ModularComponentModelFilterMixin,
-    ModularComponentTemplateFilterMixin,
-    RackBaseFilterMixin,
     RenderConfigFilterMixin,
     RenderConfigFilterMixin,
 )
 )
 
 
@@ -75,6 +71,8 @@ __all__ = (
     'ModuleTypeFilter',
     'ModuleTypeFilter',
     'ModuleTypeProfileFilter',
     'ModuleTypeProfileFilter',
     'PlatformFilter',
     'PlatformFilter',
+    'PortMappingFilter',
+    'PortTemplateMappingFilter',
     'PowerFeedFilter',
     'PowerFeedFilter',
     'PowerOutletFilter',
     'PowerOutletFilter',
     'PowerOutletTemplateFilter',
     'PowerOutletTemplateFilter',
@@ -96,7 +94,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.Cable, lookups=True)
 @strawberry_django.filter_type(models.Cable, lookups=True)
-class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
+class CableFilter(TenancyFilterMixin, PrimaryModelFilter):
     type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['CableTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -119,7 +117,7 @@ class CableFilter(PrimaryModelFilterMixin, TenancyFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.CableTermination, lookups=True)
 @strawberry_django.filter_type(models.CableTermination, lookups=True)
-class CableTerminationFilter(ChangeLogFilterMixin):
+class CableTerminationFilter(ChangeLoggedModelFilter):
     cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     cable: Annotated['CableFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     cable_id: ID | None = strawberry_django.filter_field()
     cable_id: ID | None = strawberry_django.filter_field()
     cable_end: BaseFilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     cable_end: BaseFilterLookup[Annotated['CableEndEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -132,7 +130,7 @@ class CableTerminationFilter(ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ConsolePort, lookups=True)
 @strawberry_django.filter_type(models.ConsolePort, lookups=True)
-class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class ConsolePortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -142,14 +140,14 @@ class ConsolePortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
 
 
 
 
 @strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
 @strawberry_django.filter_type(models.ConsolePortTemplate, lookups=True)
-class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin):
+class ConsolePortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
 @strawberry_django.filter_type(models.ConsoleServerPort, lookups=True)
-class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class ConsoleServerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -159,7 +157,7 @@ class ConsoleServerPortFilter(ModularComponentModelFilterMixin, CabledObjectMode
 
 
 
 
 @strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
 @strawberry_django.filter_type(models.ConsoleServerPortTemplate, lookups=True)
-class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin):
+class ConsoleServerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['ConsolePortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -172,7 +170,7 @@ class DeviceFilter(
     ImageAttachmentFilterMixin,
     ImageAttachmentFilterMixin,
     RenderConfigFilterMixin,
     RenderConfigFilterMixin,
     ConfigContextFilterMixin,
     ConfigContextFilterMixin,
-    PrimaryModelFilterMixin,
+    PrimaryModelFilter,
 ):
 ):
     device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -285,7 +283,7 @@ class DeviceFilter(
 
 
 
 
 @strawberry_django.filter_type(models.DeviceBay, lookups=True)
 @strawberry_django.filter_type(models.DeviceBay, lookups=True)
-class DeviceBayFilter(ComponentModelFilterMixin):
+class DeviceBayFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     installed_device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -293,12 +291,12 @@ class DeviceBayFilter(ComponentModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
 @strawberry_django.filter_type(models.DeviceBayTemplate, lookups=True)
-class DeviceBayTemplateFilter(ComponentTemplateFilterMixin):
+class DeviceBayTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
 @strawberry_django.filter_type(models.InventoryItemTemplate, lookups=True)
-class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
+class InventoryItemTemplateFilter(ComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     parent: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -318,7 +316,7 @@ class InventoryItemTemplateFilter(ComponentTemplateFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.DeviceRole, lookups=True)
 @strawberry_django.filter_type(models.DeviceRole, lookups=True)
-class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
+class DeviceRoleFilter(RenderConfigFilterMixin, OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -326,7 +324,7 @@ class DeviceRoleFilter(OrganizationalModelFilterMixin, RenderConfigFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.DeviceType, lookups=True)
 @strawberry_django.filter_type(models.DeviceType, lookups=True)
-class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
+class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -402,41 +400,58 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
 
 
 
 
 @strawberry_django.filter_type(models.FrontPort, lookups=True)
 @strawberry_django.filter_type(models.FrontPort, lookups=True)
-class FrontPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+
+
+@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
+class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
+    type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    rear_port_id: ID | None = strawberry_django.filter_field()
-    rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
-@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
-class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin):
-    type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
+@strawberry_django.filter_type(models.PortMapping, lookups=True)
+class PortMappingFilter(BaseModelFilter):
+    device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
+    front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
+    rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+    front_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
+    rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
+
+
+@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True)
+class PortTemplateMappingFilter(BaseModelFilter):
+    device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    front_port: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-    rear_port_id: ID | None = strawberry_django.filter_field()
-    rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+    rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    front_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
+    rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.MACAddress, lookups=True)
 @strawberry_django.filter_type(models.MACAddress, lookups=True)
-class MACAddressFilter(PrimaryModelFilterMixin):
+class MACAddressFilter(PrimaryModelFilter):
     mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
     mac_address: FilterLookup[str] | None = strawberry_django.filter_field()
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -463,7 +478,12 @@ class MACAddressFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Interface, lookups=True)
 @strawberry_django.filter_type(models.Interface, lookups=True)
-class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin, CabledObjectModelFilterMixin):
+class InterfaceFilter(
+    ModularComponentFilterMixin,
+    InterfaceBaseFilterMixin,
+    CabledObjectModelFilterMixin,
+    NetBoxModelFilter
+):
     vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     vcdcs: Annotated['VirtualDeviceContextFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -553,7 +573,7 @@ class InterfaceFilter(ModularComponentModelFilterMixin, InterfaceBaseFilterMixin
 
 
 
 
 @strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
 @strawberry_django.filter_type(models.InterfaceTemplate, lookups=True)
-class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
+class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -575,7 +595,7 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.InventoryItem, lookups=True)
 @strawberry_django.filter_type(models.InventoryItem, lookups=True)
-class InventoryItemFilter(ComponentModelFilterMixin):
+class InventoryItemFilter(ComponentModelFilterMixin, NetBoxModelFilter):
     parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     parent: Annotated['InventoryItemFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -602,14 +622,14 @@ class InventoryItemFilter(ComponentModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
 @strawberry_django.filter_type(models.InventoryItemRole, lookups=True)
-class InventoryItemRoleFilter(OrganizationalModelFilterMixin):
+class InventoryItemRoleFilter(OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.Location, lookups=True)
 @strawberry_django.filter_type(models.Location, lookups=True)
-class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilterMixin):
+class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, NestedGroupModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['LocationStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -625,12 +645,12 @@ class LocationFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilt
 
 
 
 
 @strawberry_django.filter_type(models.Manufacturer, lookups=True)
 @strawberry_django.filter_type(models.Manufacturer, lookups=True)
-class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilterMixin):
+class ManufacturerFilter(ContactFilterMixin, OrganizationalModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.Module, lookups=True)
 @strawberry_django.filter_type(models.Module, lookups=True)
-class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
+class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     module_bay: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -679,7 +699,7 @@ class ModuleFilter(PrimaryModelFilterMixin, ConfigContextFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ModuleBay, lookups=True)
 @strawberry_django.filter_type(models.ModuleBay, lookups=True)
-class ModuleBayFilter(ModularComponentModelFilterMixin):
+class ModuleBayFilter(ModularComponentFilterMixin, NetBoxModelFilter):
     parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     parent: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -688,17 +708,17 @@ class ModuleBayFilter(ModularComponentModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
 @strawberry_django.filter_type(models.ModuleBayTemplate, lookups=True)
-class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
+class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     position: FilterLookup[str] | None = strawberry_django.filter_field()
     position: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
 @strawberry_django.filter_type(models.ModuleTypeProfile, lookups=True)
-class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
+class ModuleTypeProfileFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ModuleType, lookups=True)
 @strawberry_django.filter_type(models.ModuleType, lookups=True)
-class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
+class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -749,7 +769,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
 
 
 
 
 @strawberry_django.filter_type(models.Platform, lookups=True)
 @strawberry_django.filter_type(models.Platform, lookups=True)
-class PlatformFilter(OrganizationalModelFilterMixin):
+class PlatformFilter(OrganizationalModelFilter):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -761,7 +781,7 @@ class PlatformFilter(OrganizationalModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.PowerFeed, lookups=True)
 @strawberry_django.filter_type(models.PowerFeed, lookups=True)
-class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     power_panel: Annotated['PowerPanelFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -796,7 +816,7 @@ class PowerFeedFilter(CabledObjectModelFilterMixin, TenancyFilterMixin, PrimaryM
 
 
 
 
 @strawberry_django.filter_type(models.PowerOutlet, lookups=True)
 @strawberry_django.filter_type(models.PowerOutlet, lookups=True)
-class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class PowerOutletFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -816,7 +836,7 @@ class PowerOutletFilter(ModularComponentModelFilterMixin, CabledObjectModelFilte
 
 
 
 
 @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
 @strawberry_django.filter_type(models.PowerOutletTemplate, lookups=True)
-class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
+class PowerOutletTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PowerOutletTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -830,7 +850,7 @@ class PowerOutletTemplateFilter(ModularComponentModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.PowerPanel, lookups=True)
 @strawberry_django.filter_type(models.PowerPanel, lookups=True)
-class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilterMixin):
+class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
@@ -843,7 +863,7 @@ class PowerPanelFilter(ContactFilterMixin, ImageAttachmentFilterMixin, PrimaryMo
 
 
 
 
 @strawberry_django.filter_type(models.PowerPort, lookups=True)
 @strawberry_django.filter_type(models.PowerPort, lookups=True)
-class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class PowerPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -856,7 +876,7 @@ class PowerPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterM
 
 
 
 
 @strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
 @strawberry_django.filter_type(models.PowerPortTemplate, lookups=True)
-class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
+class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PowerPortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -869,7 +889,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.RackType, lookups=True)
 @strawberry_django.filter_type(models.RackType, lookups=True)
-class RackTypeFilter(RackBaseFilterMixin):
+class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -884,7 +904,14 @@ class RackTypeFilter(RackBaseFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Rack, lookups=True)
 @strawberry_django.filter_type(models.Rack, lookups=True)
-class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, RackBaseFilterMixin):
+class RackFilter(
+    ContactFilterMixin,
+    ImageAttachmentFilterMixin,
+    TenancyFilterMixin,
+    WeightFilterMixin,
+    RackFilterMixin,
+    PrimaryModelFilter
+):
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -918,7 +945,7 @@ class RackFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
 
 
 
 
 @strawberry_django.filter_type(models.RackReservation, lookups=True)
 @strawberry_django.filter_type(models.RackReservation, lookups=True)
-class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilter):
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
     rack_id: ID | None = strawberry_django.filter_field()
     units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     units: Annotated['IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -933,14 +960,14 @@ class RackReservationFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.RackRole, lookups=True)
 @strawberry_django.filter_type(models.RackRole, lookups=True)
-class RackRoleFilter(OrganizationalModelFilterMixin):
+class RackRoleFilter(OrganizationalModelFilter):
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.RearPort, lookups=True)
 @strawberry_django.filter_type(models.RearPort, lookups=True)
-class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMixin):
+class RearPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin, NetBoxModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -953,7 +980,7 @@ class RearPortFilter(ModularComponentModelFilterMixin, CabledObjectModelFilterMi
 
 
 
 
 @strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
 @strawberry_django.filter_type(models.RearPortTemplate, lookups=True)
-class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
+class RearPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['PortTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -966,7 +993,7 @@ class RearPortTemplateFilter(ModularComponentTemplateFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Region, lookups=True)
 @strawberry_django.filter_type(models.Region, lookups=True)
-class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
+class RegionFilter(ContactFilterMixin, NestedGroupModelFilter):
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -976,7 +1003,7 @@ class RegionFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Site, lookups=True)
 @strawberry_django.filter_type(models.Site, lookups=True)
-class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     status: BaseFilterLookup[Annotated['SiteStatusEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
@@ -1012,7 +1039,7 @@ class SiteFilter(ContactFilterMixin, ImageAttachmentFilterMixin, TenancyFilterMi
 
 
 
 
 @strawberry_django.filter_type(models.SiteGroup, lookups=True)
 @strawberry_django.filter_type(models.SiteGroup, lookups=True)
-class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
+class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilter):
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     prefixes: Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -1022,7 +1049,7 @@ class SiteGroupFilter(ContactFilterMixin, NestedGroupModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VirtualChassis, lookups=True)
 @strawberry_django.filter_type(models.VirtualChassis, lookups=True)
-class VirtualChassisFilter(PrimaryModelFilterMixin):
+class VirtualChassisFilter(PrimaryModelFilter):
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
     master_id: ID | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -1034,7 +1061,7 @@ class VirtualChassisFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
 @strawberry_django.filter_type(models.VirtualDeviceContext, lookups=True)
-class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class VirtualDeviceContextFilter(TenancyFilterMixin, PrimaryModelFilter):
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     device_id: ID | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()

+ 28 - 4
netbox/dcim/graphql/types.py

@@ -385,7 +385,8 @@ class DeviceTypeType(PrimaryObjectType):
 )
 )
 class FrontPortType(ModularComponentType, CabledObjectMixin):
 class FrontPortType(ModularComponentType, CabledObjectMixin):
     color: str
     color: str
-    rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
+
+    mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -396,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
 )
 )
 class FrontPortTemplateType(ModularComponentTemplateType):
 class FrontPortTemplateType(ModularComponentTemplateType):
     color: str
     color: str
-    rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+
+    mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -636,6 +638,28 @@ class PlatformType(NestedGroupObjectType):
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
+@strawberry_django.type(
+    models.PortMapping,
+    fields='__all__',
+    filters=PortMappingFilter,
+    pagination=True
+)
+class PortMappingType(ModularComponentTemplateType):
+    front_port: Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]
+    rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
+
+
+@strawberry_django.type(
+    models.PortTemplateMapping,
+    fields='__all__',
+    filters=PortTemplateMappingFilter,
+    pagination=True
+)
+class PortMappingTemplateType(ModularComponentTemplateType):
+    front_port: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+    rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
+
+
 @strawberry_django.type(
 @strawberry_django.type(
     models.PowerFeed,
     models.PowerFeed,
     exclude=['_path'],
     exclude=['_path'],
@@ -768,7 +792,7 @@ class RackRoleType(OrganizationalObjectType):
 class RearPortType(ModularComponentType, CabledObjectMixin):
 class RearPortType(ModularComponentType, CabledObjectMixin):
     color: str
     color: str
 
 
-    frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
+    mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(
@@ -780,7 +804,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
 class RearPortTemplateType(ModularComponentTemplateType):
 class RearPortTemplateType(ModularComponentTemplateType):
     color: str
     color: str
 
 
-    frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(

+ 10 - 8
netbox/dcim/migrations/0216_latitude_longitude_validators.py

@@ -1,3 +1,5 @@
+import decimal
+
 import django.core.validators
 import django.core.validators
 from django.db import migrations, models
 from django.db import migrations, models
 
 
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
                 max_digits=8,
                 max_digits=8,
                 null=True,
                 null=True,
                 validators=[
                 validators=[
-                    django.core.validators.MinValueValidator(-90.0),
-                    django.core.validators.MaxValueValidator(90.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
                 ],
                 ],
             ),
             ),
         ),
         ),
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
                 max_digits=9,
                 max_digits=9,
                 null=True,
                 null=True,
                 validators=[
                 validators=[
-                    django.core.validators.MinValueValidator(-180.0),
-                    django.core.validators.MaxValueValidator(180.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
                 ],
                 ],
             ),
             ),
         ),
         ),
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
                 max_digits=8,
                 max_digits=8,
                 null=True,
                 null=True,
                 validators=[
                 validators=[
-                    django.core.validators.MinValueValidator(-90.0),
-                    django.core.validators.MaxValueValidator(90.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
                 ],
                 ],
             ),
             ),
         ),
         ),
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
                 max_digits=9,
                 max_digits=9,
                 null=True,
                 null=True,
                 validators=[
                 validators=[
-                    django.core.validators.MinValueValidator(-180.0),
-                    django.core.validators.MaxValueValidator(180.0),
+                    django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
+                    django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
                 ],
                 ],
             ),
             ),
         ),
         ),

+ 219 - 0
netbox/dcim/migrations/0222_port_mappings.py

@@ -0,0 +1,219 @@
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations
+from django.db import models
+from itertools import islice
+
+
+def chunked(iterable, size):
+    """
+    Yield successive chunks of a given size from an iterator.
+    """
+    iterator = iter(iterable)
+    while chunk := list(islice(iterator, size)):
+        yield chunk
+
+
+def populate_port_template_mappings(apps, schema_editor):
+    FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate')
+    PortTemplateMapping = apps.get_model('dcim', 'PortTemplateMapping')
+
+    front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000)
+
+    def generate_copies():
+        for front_port in front_ports:
+            yield PortTemplateMapping(
+                device_type_id=front_port.device_type_id,
+                module_type_id=front_port.module_type_id,
+                front_port_id=front_port.pk,
+                front_port_position=1,
+                rear_port_id=front_port.rear_port_id,
+                rear_port_position=front_port.rear_port_position,
+            )
+
+    # Bulk insert in streaming batches
+    for chunk in chunked(generate_copies(), 1000):
+        PortTemplateMapping.objects.bulk_create(chunk, batch_size=1000)
+
+
+def populate_port_mappings(apps, schema_editor):
+    FrontPort = apps.get_model('dcim', 'FrontPort')
+    PortMapping = apps.get_model('dcim', 'PortMapping')
+
+    front_ports = FrontPort.objects.iterator(chunk_size=1000)
+
+    def generate_copies():
+        for front_port in front_ports:
+            yield PortMapping(
+                device_id=front_port.device_id,
+                front_port_id=front_port.pk,
+                front_port_position=1,
+                rear_port_id=front_port.rear_port_id,
+                rear_port_position=front_port.rear_port_position,
+            )
+
+    # Bulk insert in streaming batches
+    for chunk in chunked(generate_copies(), 1000):
+        PortMapping.objects.bulk_create(chunk, batch_size=1000)
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0221_cable_position'),
+    ]
+
+    operations = [
+        # Create PortTemplateMapping model (for DeviceTypes)
+        migrations.CreateModel(
+            name='PortTemplateMapping',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                (
+                    'front_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    )
+                ),
+                (
+                    'rear_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    )
+                ),
+                (
+                    'device_type',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.devicetype',
+                        related_name='port_mappings',
+                        blank=True,
+                        null=True
+                    )
+                ),
+                (
+                    'module_type',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.moduletype',
+                        related_name='port_mappings',
+                        blank=True,
+                        null=True
+                    )
+                ),
+                (
+                    'front_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.frontporttemplate',
+                        related_name='mappings'
+                    )
+                ),
+                (
+                    'rear_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.rearporttemplate',
+                        related_name='mappings'
+                    )
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='porttemplatemapping',
+            constraint=models.UniqueConstraint(
+                fields=('front_port', 'front_port_position'),
+                name='dcim_porttemplatemapping_unique_front_port_position'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='porttemplatemapping',
+            constraint=models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='dcim_porttemplatemapping_unique_rear_port_position'
+            ),
+        ),
+
+        # Create PortMapping model (for Devices)
+        migrations.CreateModel(
+            name='PortMapping',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                (
+                    'front_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024)
+                        ]
+                    ),
+                ),
+                (
+                    'rear_port_position',
+                    models.PositiveSmallIntegerField(
+                        default=1,
+                        validators=[
+                            django.core.validators.MinValueValidator(1),
+                            django.core.validators.MaxValueValidator(1024),
+                        ]
+                    ),
+                ),
+                (
+                    'device',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.device',
+                        related_name='port_mappings'
+                    )
+                ),
+                (
+                    'front_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.frontport',
+                        related_name='mappings'
+                    )
+                ),
+                (
+                    'rear_port',
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='dcim.rearport',
+                        related_name='mappings'
+                    )
+                ),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name='portmapping',
+            constraint=models.UniqueConstraint(
+                fields=('front_port', 'front_port_position'),
+                name='dcim_portmapping_unique_front_port_position'
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='portmapping',
+            constraint=models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='dcim_portmapping_unique_rear_port_position'
+            ),
+        ),
+
+        # Data migration
+        migrations.RunPython(
+            code=populate_port_template_mappings,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=populate_port_mappings,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 65 - 0
netbox/dcim/migrations/0223_frontport_positions.py

@@ -0,0 +1,65 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0222_port_mappings'),
+    ]
+
+    operations = [
+        # Remove rear_port & rear_port_position from FrontPortTemplate
+        migrations.RemoveConstraint(
+            model_name='frontporttemplate',
+            name='dcim_frontporttemplate_unique_rear_port_position',
+        ),
+        migrations.RemoveField(
+            model_name='frontporttemplate',
+            name='rear_port',
+        ),
+        migrations.RemoveField(
+            model_name='frontporttemplate',
+            name='rear_port_position',
+        ),
+
+        # Add positions on FrontPortTemplate
+        migrations.AddField(
+            model_name='frontporttemplate',
+            name='positions',
+            field=models.PositiveSmallIntegerField(
+                default=1,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024)
+                ]
+            ),
+        ),
+
+        # Remove rear_port & rear_port_position from FrontPort
+        migrations.RemoveConstraint(
+            model_name='frontport',
+            name='dcim_frontport_unique_rear_port_position',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='rear_port',
+        ),
+        migrations.RemoveField(
+            model_name='frontport',
+            name='rear_port_position',
+        ),
+
+        # Add positions on FrontPort
+        migrations.AddField(
+            model_name='frontport',
+            name='positions',
+            field=models.PositiveSmallIntegerField(
+                default=1,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024)
+                ]
+            ),
+        ),
+    ]

+ 1 - 1
netbox/dcim/migrations/0222_add_comments_to_organizationalmodel.py → netbox/dcim/migrations/0224_add_comments_to_organizationalmodel.py

@@ -6,7 +6,7 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
-        ('dcim', '0221_cable_position'),
+        ('dcim', '0223_frontport_positions'),
     ]
     ]
 
 
     operations = [
     operations = [

+ 61 - 0
netbox/dcim/models/base.py

@@ -0,0 +1,61 @@
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from dcim.constants import PORT_POSITION_MAX, PORT_POSITION_MIN
+
+__all__ = (
+    'PortMappingBase',
+)
+
+
+class PortMappingBase(models.Model):
+    """
+    Base class for PortMapping and PortTemplateMapping
+    """
+    front_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=(
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX),
+        ),
+    )
+    rear_port_position = models.PositiveSmallIntegerField(
+        default=1,
+        validators=(
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX),
+        ),
+    )
+
+    _netbox_private = True
+
+    class Meta:
+        abstract = True
+        constraints = (
+            models.UniqueConstraint(
+                fields=('front_port', 'front_port_position'),
+                name='%(app_label)s_%(class)s_unique_front_port_position'
+            ),
+            models.UniqueConstraint(
+                fields=('rear_port', 'rear_port_position'),
+                name='%(app_label)s_%(class)s_unique_rear_port_position'
+            ),
+        )
+
+    def clean(self):
+        super().clean()
+
+        # Validate rear port position
+        if self.rear_port_position > self.rear_port.positions:
+            raise ValidationError({
+                "rear_port_position": _(
+                    "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
+                    "positions."
+                ).format(
+                    rear_port_position=self.rear_port_position,
+                    name=self.rear_port.name,
+                    positions=self.rear_port.positions
+                )
+            })

+ 66 - 48
netbox/dcim/models/cables.py

@@ -1,4 +1,5 @@
 import itertools
 import itertools
+import logging
 
 
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -22,7 +23,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.serialization import deserialize_object, serialize_object
 from utilities.serialization import deserialize_object, serialize_object
 from wireless.models import WirelessLink
 from wireless.models import WirelessLink
-from .device_components import FrontPort, PathEndpoint, RearPort
+from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
 
 
 __all__ = (
 __all__ = (
     'Cable',
     'Cable',
@@ -30,6 +31,8 @@ __all__ = (
     'CableTermination',
     'CableTermination',
 )
 )
 
 
+logger = logging.getLogger(f'netbox.{__name__}')
+
 trace_paths = Signal()
 trace_paths = Signal()
 
 
 
 
@@ -666,7 +669,13 @@ class CablePath(models.Model):
         is_active = True
         is_active = True
         is_split = False
         is_split = False
 
 
+        logger.debug(f'Tracing cable path from {terminations}...')
+
+        segment = 0
         while terminations:
         while terminations:
+            segment += 1
+            logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}')
+            logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}')
 
 
             # Terminations must all be of the same type
             # Terminations must all be of the same type
             if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
             if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
@@ -697,7 +706,10 @@ class CablePath(models.Model):
                 position_stack.append([terminations[0].cable_position])
                 position_stack.append([terminations[0].cable_position])
 
 
             # Step 2: Determine the attached links (Cable or WirelessLink), if any
             # Step 2: Determine the attached links (Cable or WirelessLink), if any
-            links = [termination.link for termination in terminations if termination.link is not None]
+            links = list(dict.fromkeys(
+                termination.link for termination in terminations if termination.link is not None
+            ))
+            logger.debug(f'[Path segment #{segment}] Links: {links}')
             if len(links) == 0:
             if len(links) == 0:
                 if len(path) == 1:
                 if len(path) == 1:
                     # If this is the start of the path and no link exists, return None
                     # If this is the start of the path and no link exists, return None
@@ -760,10 +772,13 @@ class CablePath(models.Model):
                     link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
                     link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
                 ]
                 ]
 
 
+            logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}')
+
             # Remote Terminations must all be of the same type, otherwise return a split path
             # Remote Terminations must all be of the same type, otherwise return a split path
             if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
             if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
                 is_complete = False
                 is_complete = False
                 is_split = True
                 is_split = True
+                logger.debug('Remote termination types differ; aborting trace.')
                 break
                 break
 
 
             # Step 7: Record the far-end termination object(s)
             # Step 7: Record the far-end termination object(s)
@@ -777,58 +792,53 @@ class CablePath(models.Model):
 
 
             if isinstance(remote_terminations[0], FrontPort):
             if isinstance(remote_terminations[0], FrontPort):
                 # Follow FrontPorts to their corresponding RearPorts
                 # Follow FrontPorts to their corresponding RearPorts
-                rear_ports = RearPort.objects.filter(
-                    pk__in=[t.rear_port_id for t in remote_terminations]
-                )
-                if len(rear_ports) > 1 or rear_ports[0].positions > 1:
-                    position_stack.append([fp.rear_port_position for fp in remote_terminations])
+                if remote_terminations[0].positions > 1 and position_stack:
+                    positions = position_stack.pop()
+                    q_filter = Q()
+                    for rt in remote_terminations:
+                        q_filter |= Q(front_port=rt, front_port_position__in=positions)
+                    port_mappings = PortMapping.objects.filter(q_filter)
+                elif remote_terminations[0].positions > 1:
+                    is_split = True
+                    logger.debug(
+                        'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
+                        'trace.'
+                    )
+                    break
+                else:
+                    port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
+                if not port_mappings:
+                    break
 
 
-                terminations = rear_ports
+                # Compile the list of RearPorts without duplication or altering their ordering
+                terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings))
+                if any(t.positions > 1 for t in terminations):
+                    position_stack.append([mapping.rear_port_position for mapping in port_mappings])
 
 
             elif isinstance(remote_terminations[0], RearPort):
             elif isinstance(remote_terminations[0], RearPort):
-                if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
-                    front_ports = FrontPort.objects.filter(
-                        rear_port_id__in=[rp.pk for rp in remote_terminations],
-                        rear_port_position=1
-                    )
-                # Obtain the individual front ports based on the termination and all positions
-                elif len(remote_terminations) > 1 and position_stack:
+                # Follow RearPorts to their corresponding FrontPorts
+                if remote_terminations[0].positions > 1 and position_stack:
                     positions = position_stack.pop()
                     positions = position_stack.pop()
-
-                    # Ensure we have a number of positions equal to the amount of remote terminations
-                    if len(remote_terminations) != len(positions):
-                        raise UnsupportedCablePath(
-                            _("All positions counts within the path on opposite ends of links must match")
-                        )
-
-                    # Get our front ports
                     q_filter = Q()
                     q_filter = Q()
                     for rt in remote_terminations:
                     for rt in remote_terminations:
-                        position = positions.pop()
-                        q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
-                    if q_filter is Q():
-                        raise UnsupportedCablePath(_("Remote termination position filter is missing"))
-                    front_ports = FrontPort.objects.filter(q_filter)
-                # Obtain the individual front ports based on the termination and position
-                elif position_stack:
-                    front_ports = FrontPort.objects.filter(
-                        rear_port_id=remote_terminations[0].pk,
-                        rear_port_position__in=position_stack.pop()
+                        q_filter |= Q(rear_port=rt, rear_port_position__in=positions)
+                    port_mappings = PortMapping.objects.filter(q_filter)
+                elif remote_terminations[0].positions > 1:
+                    is_split = True
+                    logger.debug(
+                        'Encountered rear port mapped to multiple front ports but position stack is empty; aborting '
+                        'trace.'
                     )
                     )
-                # If all rear ports have a single position, we can just get the front ports
-                elif all([rp.positions == 1 for rp in remote_terminations]):
-                    front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations])
-
-                    if len(front_ports) != len(remote_terminations):
-                        # Some rear ports does not have a front port
-                        is_split = True
-                        break
+                    break
                 else:
                 else:
-                    # No position indicated: path has split, so we stop at the RearPorts
-                    is_split = True
+                    port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations)
+                if not port_mappings:
                     break
                     break
 
 
-                terminations = front_ports
+                # Compile the list of FrontPorts without duplication or altering their ordering
+                terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings))
+                if any(t.positions > 1 for t in terminations):
+                    position_stack.append([mapping.front_port_position for mapping in port_mappings])
 
 
             elif isinstance(remote_terminations[0], CircuitTermination):
             elif isinstance(remote_terminations[0], CircuitTermination):
                 # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
                 # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
@@ -876,6 +886,7 @@ class CablePath(models.Model):
                     # Unsupported topology, mark as split and exit
                     # Unsupported topology, mark as split and exit
                     is_complete = False
                     is_complete = False
                     is_split = True
                     is_split = True
+                    logger.warning('Encountered an unsupported topology; aborting trace.')
                 break
                 break
 
 
         return cls(
         return cls(
@@ -954,16 +965,23 @@ class CablePath(models.Model):
 
 
         # RearPort splitting to multiple FrontPorts with no stack position
         # RearPort splitting to multiple FrontPorts with no stack position
         if type(nodes[0]) is RearPort:
         if type(nodes[0]) is RearPort:
-            return FrontPort.objects.filter(rear_port__in=nodes)
+            return [
+                mapping.front_port for mapping in
+                PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port')
+            ]
         # Cable terminating to multiple FrontPorts mapped to different
         # Cable terminating to multiple FrontPorts mapped to different
         # RearPorts connected to different cables
         # RearPorts connected to different cables
-        elif type(nodes[0]) is FrontPort:
-            return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
+        if type(nodes[0]) is FrontPort:
+            return [
+                mapping.rear_port for mapping in
+                PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port')
+            ]
         # Cable terminating to multiple CircuitTerminations
         # Cable terminating to multiple CircuitTerminations
-        elif type(nodes[0]) is CircuitTermination:
+        if type(nodes[0]) is CircuitTermination:
             return [
             return [
                 ct.get_peer_termination() for ct in nodes
                 ct.get_peer_termination() for ct in nodes
             ]
             ]
+        return []
 
 
     def get_asymmetric_nodes(self):
     def get_asymmetric_nodes(self):
         """
         """

+ 84 - 48
netbox/dcim/models/device_component_templates.py

@@ -7,6 +7,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
@@ -28,6 +29,7 @@ __all__ = (
     'InterfaceTemplate',
     'InterfaceTemplate',
     'InventoryItemTemplate',
     'InventoryItemTemplate',
     'ModuleBayTemplate',
     'ModuleBayTemplate',
+    'PortTemplateMapping',
     'PowerOutletTemplate',
     'PowerOutletTemplate',
     'PowerPortTemplate',
     'PowerPortTemplate',
     'RearPortTemplate',
     'RearPortTemplate',
@@ -518,6 +520,53 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         }
         }
 
 
 
 
+class PortTemplateMapping(PortMappingBase):
+    """
+    Maps a FrontPortTemplate & position to a RearPortTemplate & position.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='port_mappings',
+        blank=True,
+        null=True,
+    )
+    module_type = models.ForeignKey(
+        to='dcim.ModuleType',
+        on_delete=models.CASCADE,
+        related_name='port_mappings',
+        blank=True,
+        null=True,
+    )
+    front_port = models.ForeignKey(
+        to='dcim.FrontPortTemplate',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPortTemplate',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+
+    def clean(self):
+        super().clean()
+
+        # Validate rear port assignment
+        if self.front_port.device_type_id != self.rear_port.device_type_id:
+            raise ValidationError({
+                "rear_port": _("Rear port ({rear_port}) must belong to the same device type").format(
+                    rear_port=self.rear_port
+                )
+            })
+
+    def save(self, *args, **kwargs):
+        # Associate the mapping with the parent DeviceType/ModuleType
+        self.device_type = self.front_port.device_type
+        self.module_type = self.front_port.module_type
+        super().save(*args, **kwargs)
+
+
 class FrontPortTemplate(ModularComponentTemplateModel):
 class FrontPortTemplate(ModularComponentTemplateModel):
     """
     """
     Template for a pass-through port on the front of a new Device.
     Template for a pass-through port on the front of a new Device.
@@ -531,18 +580,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('color'),
         verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
-    rear_port = models.ForeignKey(
-        to='dcim.RearPortTemplate',
-        on_delete=models.CASCADE,
-        related_name='frontport_templates'
-    )
-    rear_port_position = models.PositiveSmallIntegerField(
-        verbose_name=_('rear port position'),
+    positions = models.PositiveSmallIntegerField(
+        verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
-        ]
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
+        ],
     )
     )
 
 
     component_model = FrontPort
     component_model = FrontPort
@@ -557,10 +601,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
                 fields=('module_type', 'name'),
                 fields=('module_type', 'name'),
                 name='%(app_label)s_%(class)s_unique_module_type_name'
                 name='%(app_label)s_%(class)s_unique_module_type_name'
             ),
             ),
-            models.UniqueConstraint(
-                fields=('rear_port', 'rear_port_position'),
-                name='%(app_label)s_%(class)s_unique_rear_port_position'
-            ),
         )
         )
         verbose_name = _('front port template')
         verbose_name = _('front port template')
         verbose_name_plural = _('front port templates')
         verbose_name_plural = _('front port templates')
@@ -568,40 +608,23 @@ class FrontPortTemplate(ModularComponentTemplateModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        try:
-
-            # Validate rear port assignment
-            if self.rear_port.device_type != self.device_type:
-                raise ValidationError(
-                    _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
-                )
-
-            # Validate rear port position assignment
-            if self.rear_port_position > self.rear_port.positions:
-                raise ValidationError(
-                    _("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
-                        position=self.rear_port_position,
-                        name=self.rear_port.name,
-                        count=self.rear_port.positions
-                    )
-                )
-
-        except RearPortTemplate.DoesNotExist:
-            pass
+        # Check that positions is greater than or equal to the number of associated RearPortTemplates
+        if not self._state.adding:
+            mapping_count = self.mappings.count()
+            if self.positions < mapping_count:
+                raise ValidationError({
+                    "positions": _(
+                        "The number of positions cannot be less than the number of mapped rear port templates ({count})"
+                    ).format(count=mapping_count)
+                })
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        if self.rear_port:
-            rear_port_name = self.rear_port.resolve_name(kwargs.get('module'))
-            rear_port = RearPort.objects.get(name=rear_port_name, **kwargs)
-        else:
-            rear_port = None
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.resolve_label(kwargs.get('module')),
             label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             color=self.color,
             color=self.color,
-            rear_port=rear_port,
-            rear_port_position=self.rear_port_position,
+            positions=self.positions,
             **kwargs
             **kwargs
         )
         )
     instantiate.do_not_call_in_templates = True
     instantiate.do_not_call_in_templates = True
@@ -611,8 +634,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             'name': self.name,
             'name': self.name,
             'type': self.type,
             'type': self.type,
             'color': self.color,
             'color': self.color,
-            'rear_port': self.rear_port.name,
-            'rear_port_position': self.rear_port_position,
+            'positions': self.positions,
             'label': self.label,
             'label': self.label,
             'description': self.description,
             'description': self.description,
         }
         }
@@ -635,9 +657,9 @@ class RearPortTemplate(ModularComponentTemplateModel):
         verbose_name=_('positions'),
         verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
-        ]
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
+        ],
     )
     )
 
 
     component_model = RearPort
     component_model = RearPort
@@ -646,6 +668,20 @@ class RearPortTemplate(ModularComponentTemplateModel):
         verbose_name = _('rear port template')
         verbose_name = _('rear port template')
         verbose_name_plural = _('rear port templates')
         verbose_name_plural = _('rear port templates')
 
 
+    def clean(self):
+        super().clean()
+
+        # Check that positions is greater than or equal to the number of associated FrontPortTemplates
+        if not self._state.adding:
+            mapping_count = self.mappings.count()
+            if self.positions < mapping_count:
+                raise ValidationError({
+                    "positions": _(
+                        "The number of positions cannot be less than the number of mapped front port templates "
+                        "({count})"
+                    ).format(count=mapping_count)
+                })
+
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
@@ -687,8 +723,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
-            name=self.name,
-            label=self.label,
+            name=self.resolve_name(kwargs.get('module')),
+            label=self.resolve_label(kwargs.get('module')),
             position=self.position,
             position=self.position,
             **kwargs
             **kwargs
         )
         )

+ 58 - 46
netbox/dcim/models/device_components.py

@@ -11,6 +11,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import WWNField
 from dcim.fields import WWNField
+from dcim.models.base import PortMappingBase
 from dcim.models.mixins import InterfaceValidationMixin
 from dcim.models.mixins import InterfaceValidationMixin
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, NetBoxModel
 from netbox.models import OrganizationalModel, NetBoxModel
@@ -35,6 +36,7 @@ __all__ = (
     'InventoryItemRole',
     'InventoryItemRole',
     'ModuleBay',
     'ModuleBay',
     'PathEndpoint',
     'PathEndpoint',
+    'PortMapping',
     'PowerOutlet',
     'PowerOutlet',
     'PowerPort',
     'PowerPort',
     'RearPort',
     'RearPort',
@@ -208,10 +210,6 @@ class CabledObjectModel(models.Model):
                 raise ValidationError({
                 raise ValidationError({
                     "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
                     "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
                 })
                 })
-            if not self.cable_position:
-                raise ValidationError({
-                    "cable_position": _("Must specify cable termination position when attaching a cable.")
-                })
         if self.cable_end and not self.cable:
         if self.cable_end and not self.cable:
             raise ValidationError({
             raise ValidationError({
                 "cable_end": _("Cable end must not be set without a cable.")
                 "cable_end": _("Cable end must not be set without a cable.")
@@ -1069,6 +1067,43 @@ class Interface(
 # Pass-through ports
 # Pass-through ports
 #
 #
 
 
+class PortMapping(PortMappingBase):
+    """
+    Maps a FrontPort & position to a RearPort & position.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='port_mappings',
+    )
+    front_port = models.ForeignKey(
+        to='dcim.FrontPort',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+    rear_port = models.ForeignKey(
+        to='dcim.RearPort',
+        on_delete=models.CASCADE,
+        related_name='mappings',
+    )
+
+    def clean(self):
+        super().clean()
+
+        # Both ports must belong to the same device
+        if self.front_port.device_id != self.rear_port.device_id:
+            raise ValidationError({
+                "rear_port": _("Rear port ({rear_port}) must belong to the same device").format(
+                    rear_port=self.rear_port
+                )
+            })
+
+    def save(self, *args, **kwargs):
+        # Associate the mapping with the parent Device
+        self.device = self.front_port.device
+        super().save(*args, **kwargs)
+
+
 class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     """
     """
     A pass-through port on the front of a Device.
     A pass-through port on the front of a Device.
@@ -1082,22 +1117,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
         verbose_name=_('color'),
         verbose_name=_('color'),
         blank=True
         blank=True
     )
     )
-    rear_port = models.ForeignKey(
-        to='dcim.RearPort',
-        on_delete=models.CASCADE,
-        related_name='frontports'
-    )
-    rear_port_position = models.PositiveSmallIntegerField(
-        verbose_name=_('rear port position'),
+    positions = models.PositiveSmallIntegerField(
+        verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
         ],
         ],
-        help_text=_('Mapped position on corresponding rear port')
     )
     )
 
 
-    clone_fields = ('device', 'type', 'color')
+    clone_fields = ('device', 'type', 'color', 'positions')
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
         constraints = (
         constraints = (
@@ -1105,10 +1134,6 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
                 fields=('device', 'name'),
                 fields=('device', 'name'),
                 name='%(app_label)s_%(class)s_unique_device_name'
                 name='%(app_label)s_%(class)s_unique_device_name'
             ),
             ),
-            models.UniqueConstraint(
-                fields=('rear_port', 'rear_port_position'),
-                name='%(app_label)s_%(class)s_unique_rear_port_position'
-            ),
         )
         )
         verbose_name = _('front port')
         verbose_name = _('front port')
         verbose_name_plural = _('front ports')
         verbose_name_plural = _('front ports')
@@ -1116,27 +1141,14 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
-        if hasattr(self, 'rear_port'):
-
-            # Validate rear port assignment
-            if self.rear_port.device != self.device:
-                raise ValidationError({
-                    "rear_port": _(
-                        "Rear port ({rear_port}) must belong to the same device"
-                    ).format(rear_port=self.rear_port)
-                })
-
-            # Validate rear port position assignment
-            if self.rear_port_position > self.rear_port.positions:
+        # Check that positions is greater than or equal to the number of associated RearPorts
+        if not self._state.adding:
+            mapping_count = self.mappings.count()
+            if self.positions < mapping_count:
                 raise ValidationError({
                 raise ValidationError({
-                    "rear_port_position": _(
-                        "Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
-                        "positions."
-                    ).format(
-                        rear_port_position=self.rear_port_position,
-                        name=self.rear_port.name,
-                        positions=self.rear_port.positions
-                    )
+                    "positions": _(
+                        "The number of positions cannot be less than the number of mapped rear ports ({count})"
+                    ).format(count=mapping_count)
                 })
                 })
 
 
 
 
@@ -1157,11 +1169,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
         verbose_name=_('positions'),
         verbose_name=_('positions'),
         default=1,
         default=1,
         validators=[
         validators=[
-            MinValueValidator(REARPORT_POSITIONS_MIN),
-            MaxValueValidator(REARPORT_POSITIONS_MAX)
+            MinValueValidator(PORT_POSITION_MIN),
+            MaxValueValidator(PORT_POSITION_MAX)
         ],
         ],
-        help_text=_('Number of front ports which may be mapped')
     )
     )
+
     clone_fields = ('device', 'type', 'color', 'positions')
     clone_fields = ('device', 'type', 'color', 'positions')
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
@@ -1173,13 +1185,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 
 
         # Check that positions count is greater than or equal to the number of associated FrontPorts
         # Check that positions count is greater than or equal to the number of associated FrontPorts
         if not self._state.adding:
         if not self._state.adding:
-            frontport_count = self.frontports.count()
-            if self.positions < frontport_count:
+            mapping_count = self.mappings.count()
+            if self.positions < mapping_count:
                 raise ValidationError({
                 raise ValidationError({
                     "positions": _(
                     "positions": _(
                         "The number of positions cannot be less than the number of mapped front ports "
                         "The number of positions cannot be less than the number of mapped front ports "
-                        "({frontport_count})"
-                    ).format(frontport_count=frontport_count)
+                        "({count})"
+                    ).format(count=mapping_count)
                 })
                 })
 
 
 
 

+ 13 - 7
netbox/dcim/models/devices.py

@@ -1,8 +1,7 @@
 import decimal
 import decimal
-import yaml
-
 from functools import cached_property
 from functools import cached_property
 
 
+import yaml
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -19,14 +18,14 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from dcim.utils import update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.config import ConfigItem
 from netbox.config import ConfigItem
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
 from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
-from netbox.models.mixins import WeightMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
+from netbox.models.mixins import WeightMixin
 from utilities.fields import ColorField, CounterCacheField
 from utilities.fields import ColorField, CounterCacheField
 from utilities.prefetch import get_prefetchable_fields
 from utilities.prefetch import get_prefetchable_fields
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
@@ -34,7 +33,6 @@ from .device_components import *
 from .mixins import RenderConfigMixin
 from .mixins import RenderConfigMixin
 from .modules import Module
 from .modules import Module
 
 
-
 __all__ = (
 __all__ = (
     'Device',
     'Device',
     'DeviceRole',
     'DeviceRole',
@@ -650,7 +648,10 @@ class Device(
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-90.0')),
+            MaxValueValidator(decimal.Decimal('90.0'))
+        ],
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
     )
     )
     longitude = models.DecimalField(
     longitude = models.DecimalField(
@@ -659,7 +660,10 @@ class Device(
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-180.0')),
+            MaxValueValidator(decimal.Decimal('180.0'))
+        ],
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
         help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
     )
     )
     services = GenericRelation(
     services = GenericRelation(
@@ -1003,6 +1007,8 @@ class Device(
             self._instantiate_components(self.device_type.interfacetemplates.all())
             self._instantiate_components(self.device_type.interfacetemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.all())
+            # Replicate any front/rear port mappings from the DeviceType
+            create_port_mappings(self, self.device_type)
             # Disable bulk_create to accommodate MPTT
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
             self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             self._instantiate_components(self.device_type.devicebaytemplates.all())

+ 0 - 2
netbox/dcim/models/modules.py

@@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
 from dcim.choices import *
 from dcim.choices import *
-from dcim.constants import MODULE_TOKEN
 from dcim.utils import update_interface_bridges
 from dcim.utils import update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
@@ -337,7 +336,6 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
             else:
             else:
                 # ModuleBays must be saved individually for MPTT
                 # ModuleBays must be saved individually for MPTT
                 for instance in create_instances:
                 for instance in create_instances:
-                    instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
                     instance.save()
                     instance.save()
 
 
             update_fields = ['module']
             update_fields = ['module']

+ 10 - 2
netbox/dcim/models/sites.py

@@ -1,3 +1,5 @@
+import decimal
+
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -211,7 +213,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        validators=[MinValueValidator(-90.0), MaxValueValidator(90.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-90.0')),
+            MaxValueValidator(decimal.Decimal('90.0'))
+        ],
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
     )
     longitude = models.DecimalField(
     longitude = models.DecimalField(
@@ -220,7 +225,10 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
         decimal_places=6,
         decimal_places=6,
         blank=True,
         blank=True,
         null=True,
         null=True,
-        validators=[MinValueValidator(-180.0), MaxValueValidator(180.0)],
+        validators=[
+            MinValueValidator(decimal.Decimal('-180.0')),
+            MaxValueValidator(decimal.Decimal('180.0'))
+        ],
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
         help_text=_('GPS coordinate in decimal format (xx.yyyyyy)')
     )
     )
 
 

+ 13 - 12
netbox/dcim/signals.py

@@ -1,5 +1,6 @@
 import logging
 import logging
 
 
+from django.db.models import Q
 from django.db.models.signals import post_save, post_delete
 from django.db.models.signals import post_save, post_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
@@ -7,7 +8,7 @@ from dcim.choices import CableEndChoices, LinkStatusChoices
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 from .models import (
 from .models import (
     Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
     Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
-    InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
+    InventoryItem, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
     VirtualChassis,
     VirtualChassis,
 )
 )
 from .models.cables import trace_paths
 from .models.cables import trace_paths
@@ -135,6 +136,17 @@ def retrace_cable_paths(instance, **kwargs):
         cablepath.retrace()
         cablepath.retrace()
 
 
 
 
+@receiver((post_delete, post_save), sender=PortMapping)
+def update_passthrough_port_paths(instance, **kwargs):
+    """
+    When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
+    """
+    for cablepath in CablePath.objects.filter(
+        Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
+    ):
+        cablepath.retrace()
+
+
 @receiver(post_delete, sender=CableTermination)
 @receiver(post_delete, sender=CableTermination)
 def nullify_connected_endpoints(instance, **kwargs):
 def nullify_connected_endpoints(instance, **kwargs):
     """
     """
@@ -150,17 +162,6 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
         cablepath.retrace()
 
 
 
 
-@receiver(post_save, sender=FrontPort)
-def extend_rearport_cable_paths(instance, created, raw, **kwargs):
-    """
-    When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
-    """
-    if created and not raw:
-        rearport = instance.rear_port
-        for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
-            cablepath.retrace()
-
-
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=VMInterface)
 @receiver(post_save, sender=VMInterface)
 def update_mac_address_interface(instance, created, raw, **kwargs):
 def update_mac_address_interface(instance, created, raw, **kwargs):

+ 22 - 18
netbox/dcim/tables/devices.py

@@ -749,12 +749,9 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
-    rear_port_position = tables.Column(
-        verbose_name=_('Position')
-    )
-    rear_port = tables.Column(
-        verbose_name=_('Rear Port'),
-        linkify=True
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}'
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:frontport_list'
         url_name='dcim:frontport_list'
@@ -763,12 +760,12 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.FrontPort
         model = models.FrontPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
-            'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
-            'inventory_items', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created',
+            'last_updated',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description',
         )
         )
 
 
 
 
@@ -786,11 +783,11 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.FrontPort
         model = models.FrontPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings',
             'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
             'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
+            'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer',
         )
         )
 
 
 
 
@@ -805,6 +802,10 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}'
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:rearport_list'
         url_name='dcim:rearport_list'
     )
     )
@@ -812,10 +813,13 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.RearPort
         model = models.RearPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
-            'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created',
+            'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'mappings', 'description',
         )
         )
-        default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
 
 
 
 
 class DeviceRearPortTable(RearPortTable):
 class DeviceRearPortTable(RearPortTable):
@@ -832,11 +836,11 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
     class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
         model = models.RearPort
         model = models.RearPort
         fields = (
         fields = (
-            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
-            'cable', 'cable_color', 'link_peer', 'tags', 'actions',
+            'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'mappings',
+            'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
+            'pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'cable', 'link_peer',
         )
         )
 
 
 
 

+ 10 - 5
netbox/dcim/tables/devicetypes.py

@@ -250,12 +250,13 @@ class InterfaceTemplateTable(ComponentTemplateTable):
 
 
 
 
 class FrontPortTemplateTable(ComponentTemplateTable):
 class FrontPortTemplateTable(ComponentTemplateTable):
-    rear_port_position = tables.Column(
-        verbose_name=_('Position')
-    )
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.rear_port}:{obj.rear_port_position}'
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -263,7 +264,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
 
 
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.FrontPortTemplate
         model = models.FrontPortTemplate
-        fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
+        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 
@@ -271,6 +272,10 @@ class RearPortTemplateTable(ComponentTemplateTable):
     color = columns.ColorColumn(
     color = columns.ColorColumn(
         verbose_name=_('Color'),
         verbose_name=_('Color'),
     )
     )
+    mappings = columns.ManyToManyColumn(
+        verbose_name=_('Mappings'),
+        transform=lambda obj: f'{obj.front_port}:{obj.front_port_position}'
+    )
     actions = columns.ActionsColumn(
     actions = columns.ActionsColumn(
         actions=('edit', 'delete'),
         actions=('edit', 'delete'),
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
         extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -278,7 +283,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
 
 
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.RearPortTemplate
         model = models.RearPortTemplate
-        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
+        fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'mappings', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 

+ 252 - 62
netbox/dcim/tests/test_api.py

@@ -973,72 +973,99 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C),
-            RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
-            RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
-            RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C),
-            RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_port_templates)
         RearPortTemplate.objects.bulk_create(rear_port_templates)
-
         front_port_templates = (
         front_port_templates = (
-            FrontPortTemplate(
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C),
+        )
+        FrontPortTemplate.objects.bulk_create(front_port_templates)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(
                 device_type=devicetype,
                 device_type=devicetype,
-                name='Front Port Template 1',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[0]
+                front_port=front_port_templates[0],
+                rear_port=rear_port_templates[0],
             ),
             ),
-            FrontPortTemplate(
+            PortTemplateMapping(
                 device_type=devicetype,
                 device_type=devicetype,
-                name='Front Port Template 2',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[1]
-            ),
-            FrontPortTemplate(
-                module_type=moduletype,
-                name='Front Port Template 5',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[4]
+                front_port=front_port_templates[1],
+                rear_port=rear_port_templates[1],
             ),
             ),
-            FrontPortTemplate(
+            PortTemplateMapping(
                 module_type=moduletype,
                 module_type=moduletype,
-                name='Front Port Template 6',
-                type=PortTypeChoices.TYPE_8P8C,
-                rear_port=rear_port_templates[5]
+                front_port=front_port_templates[2],
+                rear_port=rear_port_templates[2],
             ),
             ),
-        )
-        FrontPortTemplate.objects.bulk_create(front_port_templates)
+        ])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Front Port Template 3',
                 'name': 'Front Port Template 3',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[2].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[3].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Front Port Template 4',
                 'name': 'Front Port Template 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[3].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[4].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'module_type': moduletype.pk,
                 'module_type': moduletype.pk,
                 'name': 'Front Port Template 7',
                 'name': 'Front Port Template 7',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[6].pk,
-                'rear_port_position': 1,
-            },
-            {
-                'module_type': moduletype.pk,
-                'name': 'Front Port Template 8',
-                'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_port_templates[7].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_port_templates[5].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        cls.update_data = {
+            'type': PortTypeChoices.TYPE_LC,
+            'rear_ports': [
+                {
+                    'position': 1,
+                    'rear_port': rear_port_templates[3].pk,
+                    'rear_port_position': 1,
+                },
+            ],
+        }
+
+    def test_update_object(self):
+        super().test_update_object()
+
+        # Check that the port mapping was updated after modifying the front port template
+        front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 1')
+        rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 4')
+        self.assertTrue(
+            PortTemplateMapping.objects.filter(
+                front_port=front_port_template,
+                front_port_position=1,
+                rear_port=rear_port_template,
+                rear_port_position=1,
+            ).exists()
+        )
+
 
 
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
@@ -1057,36 +1084,104 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
             manufacturer=manufacturer, model='Module Type 1'
             manufacturer=manufacturer, model='Module Type 1'
         )
         )
 
 
+        front_port_templates = (
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=moduletype, name='Front Port Template 3', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=moduletype, name='Front Port Template 4', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=moduletype, name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=moduletype, name='Front Port Template 6', type=PortTypeChoices.TYPE_8P8C),
+        )
+        FrontPortTemplate.objects.bulk_create(front_port_templates)
         rear_port_templates = (
         rear_port_templates = (
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_port_templates)
         RearPortTemplate.objects.bulk_create(rear_port_templates)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(
+                device_type=devicetype,
+                front_port=front_port_templates[0],
+                rear_port=rear_port_templates[0],
+            ),
+            PortTemplateMapping(
+                device_type=devicetype,
+                front_port=front_port_templates[1],
+                rear_port=rear_port_templates[1],
+            ),
+            PortTemplateMapping(
+                module_type=moduletype,
+                front_port=front_port_templates[2],
+                rear_port=rear_port_templates[2],
+            ),
+        ])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Rear Port Template 4',
                 'name': 'Rear Port Template 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_port_templates[3].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device_type': devicetype.pk,
                 'device_type': devicetype.pk,
                 'name': 'Rear Port Template 5',
                 'name': 'Rear Port Template 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_port_templates[4].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'module_type': moduletype.pk,
                 'module_type': moduletype.pk,
                 'name': 'Rear Port Template 6',
                 'name': 'Rear Port Template 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-            },
-            {
-                'module_type': moduletype.pk,
-                'name': 'Rear Port Template 7',
-                'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_port_templates[5].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        cls.update_data = {
+            'type': PortTypeChoices.TYPE_LC,
+            'front_ports': [
+                {
+                    'position': 1,
+                    'front_port': front_port_templates[3].pk,
+                    'front_port_position': 1,
+                },
+            ],
+        }
+
+    def test_update_object(self):
+        super().test_update_object()
+
+        # Check that the port mapping was updated after modifying the rear port template
+        front_port_template = FrontPortTemplate.objects.get(name='Front Port Template 4')
+        rear_port_template = RearPortTemplate.objects.get(name='Rear Port Template 1')
+        self.assertTrue(
+            PortTemplateMapping.objects.filter(
+                front_port=front_port_template,
+                front_port_position=1,
+                rear_port=rear_port_template,
+                rear_port_position=1,
+            ).exists()
+        )
+
 
 
 class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
 class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
@@ -2015,51 +2110,90 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
             RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
-
         front_ports = (
         front_ports = (
-            FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
-            FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
-            FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2]),
+            FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 4',
                 'name': 'Front Port 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[3].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[3].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 5',
                 'name': 'Front Port 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[4].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[4].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Front Port 6',
                 'name': 'Front Port 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
-                'rear_port': rear_ports[5].pk,
-                'rear_port_position': 1,
+                'rear_ports': [
+                    {
+                        'position': 1,
+                        'rear_port': rear_ports[5].pk,
+                        'rear_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        cls.update_data = {
+            'type': PortTypeChoices.TYPE_LC,
+            'rear_ports': [
+                {
+                    'position': 1,
+                    'rear_port': rear_ports[3].pk,
+                    'rear_port_position': 1,
+                },
+            ],
+        }
+
+    def test_update_object(self):
+        super().test_update_object()
+
+        # Check that the port mapping was updated after modifying the front port
+        front_port = FrontPort.objects.get(name='Front Port 1')
+        rear_port = RearPort.objects.get(name='Rear Port 4')
+        self.assertTrue(
+            PortMapping.objects.filter(
+                front_port=front_port,
+                front_port_position=1,
+                rear_port=rear_port,
+                rear_port_position=1,
+            ).exists()
+        )
+
     @tag('regression')  # Issue #18991
     @tag('regression')  # Issue #18991
     def test_front_port_paths(self):
     def test_front_port_paths(self):
         device = Device.objects.first()
         device = Device.objects.first()
-        rear_port = RearPort.objects.create(
-            device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
-        )
         interface1 = Interface.objects.create(device=device, name='Interface 1')
         interface1 = Interface.objects.create(device=device, name='Interface 1')
-        front_port = FrontPort.objects.create(
-            device=device,
-            name='Rear Port 10',
-            type=PortTypeChoices.TYPE_8P8C,
-            rear_port=rear_port,
-        )
+        rear_port = RearPort.objects.create(device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C)
+        front_port = FrontPort.objects.create(device=device, name='Front Port 10', type=PortTypeChoices.TYPE_8P8C)
+        PortMapping.objects.create(device=device, front_port=front_port, rear_port=rear_port)
         Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
         Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
 
 
         self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
         self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
@@ -2086,6 +2220,15 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
         role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         role = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
         device = Device.objects.create(device_type=devicetype, role=role, name='Device 1', site=site)
 
 
+        front_ports = (
+            FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 4', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 5', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=device, name='Front Port 6', type=PortTypeChoices.TYPE_8P8C),
+        )
+        FrontPort.objects.bulk_create(front_ports)
         rear_ports = (
         rear_ports = (
             RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
@@ -2098,19 +2241,66 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 4',
                 'name': 'Rear Port 4',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[3].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 5',
                 'name': 'Rear Port 5',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[4].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Rear Port 6',
                 'name': 'Rear Port 6',
                 'type': PortTypeChoices.TYPE_8P8C,
                 'type': PortTypeChoices.TYPE_8P8C,
+                'front_ports': [
+                    {
+                        'position': 1,
+                        'front_port': front_ports[5].pk,
+                        'front_port_position': 1,
+                    },
+                ],
             },
             },
         ]
         ]
 
 
+        cls.update_data = {
+            'type': PortTypeChoices.TYPE_LC,
+            'front_ports': [
+                {
+                    'position': 1,
+                    'front_port': front_ports[3].pk,
+                    'front_port_position': 1,
+                },
+            ],
+        }
+
+    def test_update_object(self):
+        super().test_update_object()
+
+        # Check that the port mapping was updated after modifying the rear port
+        front_port = FrontPort.objects.get(name='Front Port 4')
+        rear_port = RearPort.objects.get(name='Rear Port 1')
+        self.assertTrue(
+            PortMapping.objects.filter(
+                front_port=front_port,
+                front_port_position=1,
+                rear_port=rear_port,
+                rear_port_position=1,
+            ).exists()
+        )
+
     @tag('regression')  # Issue #18991
     @tag('regression')  # Issue #18991
     def test_rear_port_paths(self):
     def test_rear_port_paths(self):
         device = Device.objects.first()
         device = Device.objects.first()

+ 649 - 254
netbox/dcim/tests/test_cablepaths.py

@@ -281,9 +281,14 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1
         )
         )
 
 
         # Create cable 1
         # Create cable 1
@@ -340,9 +345,14 @@ class LegacyCablePathTests(CablePathTestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1
         )
         )
 
 
         # Create cable 1
         # Create cable 1
@@ -403,18 +413,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -521,18 +553,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface8 = Interface.objects.create(device=self.device, name='Interface 8')
         interface8 = Interface.objects.create(device=self.device, name='Interface 8')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -680,27 +734,59 @@ class LegacyCablePathTests(CablePathTestCase):
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
         rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
         rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
-        )
-        frontport4_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1')
+        frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_1,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_2,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2, 6-7
         # Create cables 1-2, 6-7
         cable1 = Cable(
         cable1 = Cable(
@@ -801,30 +887,72 @@ class LegacyCablePathTests(CablePathTestCase):
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4)
         rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4)
         rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
         rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
-        frontport3_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3:1', rear_port=rearport3, rear_port_position=1
-        )
-        frontport3_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3:2', rear_port=rearport3, rear_port_position=2
-        )
-        frontport4_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4:1', rear_port=rearport4, rear_port_position=1
-        )
-        frontport4_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4:2', rear_port=rearport4, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 3:1')
+        frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2')
+        frontport4_1 = FrontPort.objects.create(device=self.device, name='Front Port 4:1')
+        frontport4_2 = FrontPort.objects.create(device=self.device, name='Front Port 4:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_1,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_2,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_1,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4_2,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-3, 6-8
         # Create cables 1-3, 6-8
         cable1 = Cable(
         cable1 = Cable(
@@ -928,23 +1056,50 @@ class LegacyCablePathTests(CablePathTestCase):
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 5', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport3, rear_port_position=1
-        )
-        frontport3_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport3, rear_port_position=2
-        )
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=4)
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3_1 = FrontPort.objects.create(device=self.device, name='Front Port 3:1')
+        frontport3_2 = FrontPort.objects.create(device=self.device, name='Front Port 3:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_1,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3_2,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1-2, 5-6
         # Create cables 1-2, 5-6
         cable1 = Cable(
         cable1 = Cable(
@@ -1032,13 +1187,25 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2)
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables 1
         # Create cables 1
         cable1 = Cable(
         cable1 = Cable(
@@ -1098,10 +1265,11 @@ class LegacyCablePathTests(CablePathTestCase):
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2]
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.create(
+            front_port=frontport1, front_port_position=1, rear_port=rearport1, rear_port_position=1,
         )
         )
 
 
         # Create cables
         # Create cables
@@ -1413,18 +1581,40 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
         circuittermination1 = CircuitTermination.objects.create(
         circuittermination1 = CircuitTermination.objects.create(
             circuit=self.circuit,
             circuit=self.circuit,
             termination=self.site,
             termination=self.site,
@@ -1601,22 +1791,44 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
-        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -1688,30 +1900,72 @@ class LegacyCablePathTests(CablePathTestCase):
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport1_3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:3', rear_port=rearport1, rear_port_position=3
-        )
-        frontport1_4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:4', rear_port=rearport1, rear_port_position=4
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
-        frontport2_3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:3', rear_port=rearport2, rear_port_position=3
-        )
-        frontport2_4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:4', rear_port=rearport2, rear_port_position=4
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport1_3 = FrontPort.objects.create(device=self.device, name='Front Port 1:3')
+        frontport1_4 = FrontPort.objects.create(device=self.device, name='Front Port 1:4')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        frontport2_3 = FrontPort.objects.create(device=self.device, name='Front Port 2:3')
+        frontport2_4 = FrontPort.objects.create(device=self.device, name='Front Port 2:4')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_3,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=3,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_4,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=4,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_3,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=3,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_4,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=4,
+            ),
+        ])
 
 
         # Create cables 1-2
         # Create cables 1-2
         cable1 = Cable(
         cable1 = Cable(
@@ -1858,22 +2112,44 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
-        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -1937,22 +2213,44 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
-        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -2033,30 +2331,62 @@ class LegacyCablePathTests(CablePathTestCase):
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
-        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
-        rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
-        rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4 = FrontPort.objects.create(
-            device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
-        )
-        frontport5 = FrontPort.objects.create(
-            device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
-        )
-        frontport6 = FrontPort.objects.create(
-            device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4')
+        rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5')
+        rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4')
+        frontport5 = FrontPort.objects.create(device=self.device, name='Front Port 5')
+        frontport6 = FrontPort.objects.create(device=self.device, name='Front Port 6')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport5,
+                front_port_position=1,
+                rear_port=rearport5,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport6,
+                front_port_position=1,
+                rear_port=rearport6,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],
@@ -2155,14 +2485,26 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable1 = Cable(
         cable1 = Cable(
             a_terminations=[interface1],
             a_terminations=[interface1],
@@ -2274,14 +2616,26 @@ class LegacyCablePathTests(CablePathTestCase):
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface3 = Interface.objects.create(device=self.device, name='Interface 3')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
         interface4 = Interface.objects.create(device=self.device, name='Interface 4')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -2320,14 +2674,26 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cable 2
         # Create cable 2
         cable2 = Cable(
         cable2 = Cable(
@@ -2373,10 +2739,17 @@ class LegacyCablePathTests(CablePathTestCase):
         """
         """
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables 1 and 2
         # Create cables 1 and 2
         cable1 = Cable(
         cable1 = Cable(
@@ -2478,22 +2851,44 @@ class LegacyCablePathTests(CablePathTestCase):
         )
         )
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
         interface2 = Interface.objects.create(device=self.device, name='Interface 2')
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
-        rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
-        frontport3 = FrontPort.objects.create(
-            device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
-        )
-        frontport4 = FrontPort.objects.create(
-            device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        rearport3 = RearPort.objects.create(device=device, name='Rear Port 3')
+        rearport4 = RearPort.objects.create(device=device, name='Rear Port 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 3')
+        frontport4 = FrontPort.objects.create(device=self.device, name='Front Port 4')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport3,
+                front_port_position=1,
+                rear_port=rearport3,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport4,
+                front_port_position=1,
+                rear_port=rearport4,
+                rear_port_position=1,
+            ),
+        ])
 
 
         cable2 = Cable(
         cable2 = Cable(
             a_terminations=[rearport1],
             a_terminations=[rearport1],

+ 302 - 47
netbox/dcim/tests/test_cablepaths2.py

@@ -1,5 +1,3 @@
-from unittest import skipIf
-
 from circuits.models import CircuitTermination
 from circuits.models import CircuitTermination
 from dcim.choices import CableProfileChoices
 from dcim.choices import CableProfileChoices
 from dcim.models import *
 from dcim.models import *
@@ -363,13 +361,17 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 3'),
             Interface.objects.create(device=self.device, name='Interface 3'),
             Interface.objects.create(device=self.device, name='Interface 4'),
             Interface.objects.create(device=self.device, name='Interface 4'),
         ]
         ]
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device,
-            name='Front Port 1',
-            rear_port=rearport1,
-            rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -439,18 +441,40 @@ class CablePathTests(CablePathTestCase):
         ]
         ]
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
         rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
-        frontport1_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport1_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
-        )
-        frontport2_1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
-        )
-        frontport2_2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
-        )
+        frontport1_1 = FrontPort.objects.create(device=self.device, name='Front Port 1:1')
+        frontport1_2 = FrontPort.objects.create(device=self.device, name='Front Port 1:2')
+        frontport2_1 = FrontPort.objects.create(device=self.device, name='Front Port 2:1')
+        frontport2_2 = FrontPort.objects.create(device=self.device, name='Front Port 2:2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport1_2,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_1,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2_2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=2,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -654,25 +678,47 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 2'),
             Interface.objects.create(device=self.device, name='Interface 2'),
         ]
         ]
         rear_ports = [
         rear_ports = [
-            RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1),
-            RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1),
-            RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1),
-            RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1),
+            RearPort.objects.create(device=self.device, name='Rear Port 1'),
+            RearPort.objects.create(device=self.device, name='Rear Port 2'),
+            RearPort.objects.create(device=self.device, name='Rear Port 3'),
+            RearPort.objects.create(device=self.device, name='Rear Port 4'),
         ]
         ]
         front_ports = [
         front_ports = [
-            FrontPort.objects.create(
-                device=self.device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1
+            FrontPort.objects.create(device=self.device, name='Front Port 1'),
+            FrontPort.objects.create(device=self.device, name='Front Port 2'),
+            FrontPort.objects.create(device=self.device, name='Front Port 3'),
+            FrontPort.objects.create(device=self.device, name='Front Port 4'),
+        ]
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[0],
+                front_port_position=1,
+                rear_port=rear_ports[0],
+                rear_port_position=1,
             ),
             ),
-            FrontPort.objects.create(
-                device=self.device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[1],
+                front_port_position=1,
+                rear_port=rear_ports[1],
+                rear_port_position=1,
             ),
             ),
-            FrontPort.objects.create(
-                device=self.device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[2],
+                front_port_position=1,
+                rear_port=rear_ports[2],
+                rear_port_position=1,
             ),
             ),
-            FrontPort.objects.create(
-                device=self.device, name='Front Port 4', rear_port=rear_ports[3], rear_port_position=1
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[3],
+                front_port_position=1,
+                rear_port=rear_ports[3],
+                rear_port_position=1,
             ),
             ),
-        ]
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -723,8 +769,6 @@ class CablePathTests(CablePathTestCase):
         # Test SVG generation
         # Test SVG generation
         CableTraceSVG(interfaces[0]).render()
         CableTraceSVG(interfaces[0]).render()
 
 
-    # TODO: Revisit this test under FR #20564
-    @skipIf(True, "Waiting for FR #20564")
     def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self):
     def test_223_single_path_via_multiple_pass_throughs_with_breakouts(self):
         """
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF3]
@@ -736,14 +780,26 @@ class CablePathTests(CablePathTestCase):
             Interface.objects.create(device=self.device, name='Interface 3'),
             Interface.objects.create(device=self.device, name='Interface 3'),
             Interface.objects.create(device=self.device, name='Interface 4'),
             Interface.objects.create(device=self.device, name='Interface 4'),
         ]
         ]
-        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
-        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
-        frontport1 = FrontPort.objects.create(
-            device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
-        )
-        frontport2 = FrontPort.objects.create(
-            device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
-        )
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=frontport1,
+                front_port_position=1,
+                rear_port=rearport1,
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=frontport2,
+                front_port_position=1,
+                rear_port=rearport2,
+                rear_port_position=1,
+            ),
+        ])
 
 
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -761,9 +817,6 @@ class CablePathTests(CablePathTestCase):
         cable2.clean()
         cable2.clean()
         cable2.save()
         cable2.save()
 
 
-        for path in CablePath.objects.all():
-            print(f'{path}: {path.path_objects}')
-
         # Validate paths
         # Validate paths
         self.assertPathExists(
         self.assertPathExists(
             (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]),
             (interfaces[0], cable1, [frontport1, frontport2], [rearport1, rearport2], cable2, interfaces[2]),
@@ -786,3 +839,205 @@ class CablePathTests(CablePathTestCase):
             is_active=True
             is_active=True
         )
         )
         self.assertEqual(CablePath.objects.count(), 4)
         self.assertEqual(CablePath.objects.count(), 4)
+
+    def test_304_add_port_mapping_between_connected_ports(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        cable1 = Cable(
+            a_terminations=[interface1],
+            b_terminations=[frontport1]
+        )
+        cable1.save()
+        cable2 = Cable(
+            a_terminations=[interface2],
+            b_terminations=[rearport1]
+        )
+        cable2.save()
+
+        # Check for incomplete paths
+        self.assertPathExists(
+            (interface1, cable1, frontport1),
+            is_complete=False,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1),
+            is_complete=False,
+            is_active=True
+        )
+
+        # Create a PortMapping between frontport1 and rearport1
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1,
+        )
+
+        # Check that paths are now complete
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport1, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+
+    def test_305_delete_port_mapping_between_connected_ports(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        cable1 = Cable(
+            a_terminations=[interface1],
+            b_terminations=[frontport1]
+        )
+        cable1.save()
+        cable2 = Cable(
+            a_terminations=[interface2],
+            b_terminations=[rearport1]
+        )
+        cable2.save()
+        portmapping1 = PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1,
+        )
+
+        # Check for complete paths
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport1, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+
+        # Delete the PortMapping between frontport1 and rearport1
+        portmapping1.delete()
+
+        # Check that paths are no longer complete
+        self.assertPathExists(
+            (interface1, cable1, frontport1),
+            is_complete=False,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1),
+            is_complete=False,
+            is_active=True
+        )
+
+    def test_306_change_port_mapping_between_connected_ports(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C3-- [IF3]
+        [IF2] --C2-- [FP2] [RP3] --C4-- [IF4]
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        interface3 = Interface.objects.create(device=self.device, name='Interface 3')
+        interface4 = Interface.objects.create(device=self.device, name='Interface 4')
+        frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        frontport2 = FrontPort.objects.create(device=self.device, name='Front Port 2')
+        rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2')
+        cable1 = Cable(
+            a_terminations=[interface1],
+            b_terminations=[frontport1]
+        )
+        cable1.save()
+        cable2 = Cable(
+            a_terminations=[interface2],
+            b_terminations=[frontport2]
+        )
+        cable2.save()
+        cable3 = Cable(
+            a_terminations=[interface3],
+            b_terminations=[rearport1]
+        )
+        cable3.save()
+        cable4 = Cable(
+            a_terminations=[interface4],
+            b_terminations=[rearport2]
+        )
+        cable4.save()
+        portmapping1 = PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport1,
+            rear_port_position=1,
+        )
+
+        # Verify expected initial paths
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport1, cable3, interface3),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface3, cable3, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+
+        # Delete and replace the PortMapping to connect interface1 to interface4
+        portmapping1.delete()
+        portmapping2 = PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport1,
+            front_port_position=1,
+            rear_port=rearport2,
+            rear_port_position=1,
+        )
+
+        # Verify expected new paths
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport2, cable4, interface4),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface4, cable4, rearport2, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+
+        # Delete and replace the PortMapping to connect interface2 to interface4
+        portmapping2.delete()
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport2,
+            front_port_position=1,
+            rear_port=rearport2,
+            rear_port_position=1,
+        )
+
+        # Verify expected new paths
+        self.assertPathExists(
+            (interface2, cable2, frontport2, rearport2, cable4, interface4),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface4, cable4, rearport2, frontport2, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )

+ 157 - 59
netbox/dcim/tests/test_filtersets.py

@@ -43,6 +43,13 @@ class DeviceComponentFilterSetTests:
         params = {'device_status': ['active', 'planned']}
         params = {'device_status': ['active', 'planned']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class DeviceComponentTemplateFilterSetTests:
 class DeviceComponentTemplateFilterSetTests:
 
 
@@ -1355,22 +1362,15 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(device_type=device_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
-        FrontPortTemplate.objects.bulk_create(
-            (
-                FrontPortTemplate(
-                    device_type=device_types[0],
-                    name='Front Port 1',
-                    type=PortTypeChoices.TYPE_8P8C,
-                    rear_port=rear_ports[0],
-                ),
-                FrontPortTemplate(
-                    device_type=device_types[1],
-                    name='Front Port 2',
-                    type=PortTypeChoices.TYPE_8P8C,
-                    rear_port=rear_ports[1],
-                ),
-            )
-        )
+        front_ports = (
+            FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
+        )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(device_type=device_types[0], front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(device_type=device_types[1], front_port=front_ports[1], rear_port=rear_ports[1]),
+        ])
         ModuleBayTemplate.objects.bulk_create((
         ModuleBayTemplate.objects.bulk_create((
             ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
             ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
             ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
             ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
@@ -1626,22 +1626,15 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
-        FrontPortTemplate.objects.bulk_create(
-            (
-                FrontPortTemplate(
-                    module_type=module_types[0],
-                    name='Front Port 1',
-                    type=PortTypeChoices.TYPE_8P8C,
-                    rear_port=rear_ports[0],
-                ),
-                FrontPortTemplate(
-                    module_type=module_types[1],
-                    name='Front Port 2',
-                    type=PortTypeChoices.TYPE_8P8C,
-                    rear_port=rear_ports[1],
-                ),
-            )
+        front_ports = (
+            FrontPortTemplate(module_type=module_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPortTemplate(module_type=module_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(module_type=module_types[0], front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(module_type=module_types[1], front_port=front_ports[1], rear_port=rear_ports[1]),
+        ])
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -2057,32 +2050,38 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
 
 
-        FrontPortTemplate.objects.bulk_create((
+        front_ports = (
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[0],
                 device_type=device_types[0],
                 name='Front Port 1',
                 name='Front Port 1',
-                rear_port=rear_ports[0],
                 type=PortTypeChoices.TYPE_8P8C,
                 type=PortTypeChoices.TYPE_8P8C,
+                positions=1,
                 color=ColorChoices.COLOR_RED,
                 color=ColorChoices.COLOR_RED,
                 description='foobar1'
                 description='foobar1'
             ),
             ),
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[1],
                 device_type=device_types[1],
                 name='Front Port 2',
                 name='Front Port 2',
-                rear_port=rear_ports[1],
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 type=PortTypeChoices.TYPE_110_PUNCH,
+                positions=2,
                 color=ColorChoices.COLOR_GREEN,
                 color=ColorChoices.COLOR_GREEN,
                 description='foobar2'
                 description='foobar2'
             ),
             ),
             FrontPortTemplate(
             FrontPortTemplate(
                 device_type=device_types[2],
                 device_type=device_types[2],
                 name='Front Port 3',
                 name='Front Port 3',
-                rear_port=rear_ports[2],
                 type=PortTypeChoices.TYPE_BNC,
                 type=PortTypeChoices.TYPE_BNC,
+                positions=3,
                 color=ColorChoices.COLOR_BLUE,
                 color=ColorChoices.COLOR_BLUE,
                 description='foobar3'
                 description='foobar3'
             ),
             ),
-        ))
+        )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(device_type=device_types[0], front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(device_type=device_types[1], front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortTemplateMapping(device_type=device_types[2], front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
     def test_name(self):
     def test_name(self):
         params = {'name': ['Front Port 1', 'Front Port 2']}
         params = {'name': ['Front Port 1', 'Front Port 2']}
@@ -2096,6 +2095,10 @@ class FrontPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_positions(self):
+        params = {'positions': [1, 2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
 class RearPortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
@@ -2752,10 +2755,15 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
             RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
-        FrontPort.objects.bulk_create((
-            FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
-            FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
-        ))
+        front_ports = (
+            FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C),
+            FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C),
+        )
+        FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=devices[0], front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=devices[1], front_port=front_ports[1], rear_port=rear_ports[1]),
+        ])
         ModuleBay.objects.create(device=devices[0], name='Module Bay 1')
         ModuleBay.objects.create(device=devices[0], name='Module Bay 1')
         ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
         ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
         DeviceBay.objects.bulk_create((
         DeviceBay.objects.bulk_create((
@@ -3384,9 +3392,17 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -3396,6 +3412,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -3405,6 +3422,7 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -3624,9 +3642,17 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -3636,6 +3662,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -3645,6 +3672,7 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -3864,9 +3892,17 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -3876,6 +3912,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -3885,6 +3922,7 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -4118,9 +4156,17 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -4130,6 +4176,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -4139,6 +4186,7 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -4397,9 +4445,17 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         virtual_chassis = VirtualChassis(name='Virtual Chassis')
         virtual_chassis = VirtualChassis(name='Virtual Chassis')
         virtual_chassis.save()
         virtual_chassis.save()
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1A',
                 name='Device 1A',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -4412,6 +4468,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 1B',
                 name='Device 1B',
+                tenant=tenants[1],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -4424,6 +4481,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[2],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -4433,6 +4491,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -5018,9 +5077,17 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -5030,6 +5097,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -5039,6 +5107,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -5090,8 +5159,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='A',
                 label='A',
                 type=PortTypeChoices.TYPE_8P8C,
                 type=PortTypeChoices.TYPE_8P8C,
                 color=ColorChoices.COLOR_RED,
                 color=ColorChoices.COLOR_RED,
-                rear_port=rear_ports[0],
-                rear_port_position=1,
                 description='First',
                 description='First',
                 _site=devices[0].site,
                 _site=devices[0].site,
                 _location=devices[0].location,
                 _location=devices[0].location,
@@ -5104,8 +5171,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='B',
                 label='B',
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 type=PortTypeChoices.TYPE_110_PUNCH,
                 color=ColorChoices.COLOR_GREEN,
                 color=ColorChoices.COLOR_GREEN,
-                rear_port=rear_ports[1],
-                rear_port_position=2,
                 description='Second',
                 description='Second',
                 _site=devices[1].site,
                 _site=devices[1].site,
                 _location=devices[1].location,
                 _location=devices[1].location,
@@ -5118,8 +5183,6 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 label='C',
                 label='C',
                 type=PortTypeChoices.TYPE_BNC,
                 type=PortTypeChoices.TYPE_BNC,
                 color=ColorChoices.COLOR_BLUE,
                 color=ColorChoices.COLOR_BLUE,
-                rear_port=rear_ports[2],
-                rear_port_position=3,
                 description='Third',
                 description='Third',
                 _site=devices[2].site,
                 _site=devices[2].site,
                 _location=devices[2].location,
                 _location=devices[2].location,
@@ -5130,8 +5193,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 4',
                 name='Front Port 4',
                 label='D',
                 label='D',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[3],
-                rear_port_position=1,
+                positions=2,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
@@ -5141,8 +5203,7 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 5',
                 name='Front Port 5',
                 label='E',
                 label='E',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[4],
-                rear_port_position=1,
+                positions=3,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
@@ -5152,14 +5213,21 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
                 name='Front Port 6',
                 name='Front Port 6',
                 label='F',
                 label='F',
                 type=PortTypeChoices.TYPE_FC,
                 type=PortTypeChoices.TYPE_FC,
-                rear_port=rear_ports[5],
-                rear_port_position=1,
+                positions=4,
                 _site=devices[3].site,
                 _site=devices[3].site,
                 _location=devices[3].location,
                 _location=devices[3].location,
                 _rack=devices[3].rack,
                 _rack=devices[3].rack,
             ),
             ),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=devices[0], front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=devices[1], front_port=front_ports[1], rear_port=rear_ports[1], rear_port_position=2),
+            PortMapping(device=devices[2], front_port=front_ports[2], rear_port=rear_ports[2], rear_port_position=3),
+            PortMapping(device=devices[3], front_port=front_ports[3], rear_port=rear_ports[3]),
+            PortMapping(device=devices[3], front_port=front_ports[4], rear_port=rear_ports[4]),
+            PortMapping(device=devices[3], front_port=front_ports[5], rear_port=rear_ports[5]),
+        ])
 
 
         # Cables
         # Cables
         Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
         Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save()
@@ -5182,6 +5250,10 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_positions(self):
+        params = {'positions': [2, 3]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_description(self):
     def test_description(self):
         params = {'description': ['First', 'Second']}
         params = {'description': ['First', 'Second']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -5309,9 +5381,17 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -5321,6 +5401,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -5330,6 +5411,7 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -5586,9 +5668,17 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -5598,6 +5688,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -5607,6 +5698,7 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -5759,9 +5851,17 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         )
         )
         Rack.objects.bulk_create(racks)
         Rack.objects.bulk_create(racks)
 
 
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1'),
+            Tenant(name='Tenant 2', slug='tenant-2'),
+            Tenant(name='Tenant 3', slug='tenant-3'),
+        )
+        Tenant.objects.bulk_create(tenants)
+
         devices = (
         devices = (
             Device(
             Device(
                 name='Device 1',
                 name='Device 1',
+                tenant=tenants[0],
                 device_type=device_types[0],
                 device_type=device_types[0],
                 role=roles[0],
                 role=roles[0],
                 site=sites[0],
                 site=sites[0],
@@ -5771,6 +5871,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 2',
                 name='Device 2',
+                tenant=tenants[1],
                 device_type=device_types[1],
                 device_type=device_types[1],
                 role=roles[1],
                 role=roles[1],
                 site=sites[1],
                 site=sites[1],
@@ -5780,6 +5881,7 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             ),
             ),
             Device(
             Device(
                 name='Device 3',
                 name='Device 3',
+                tenant=tenants[2],
                 device_type=device_types[2],
                 device_type=device_types[2],
                 role=roles[2],
                 role=roles[2],
                 site=sites[2],
                 site=sites[2],
@@ -6420,13 +6522,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
         console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
         power_port = PowerPort.objects.create(device=devices[0], name='Power Port 1')
         power_port = PowerPort.objects.create(device=devices[0], name='Power Port 1')
         power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 1')
         power_outlet = PowerOutlet.objects.create(device=devices[0], name='Power Outlet 1')
-        rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1', positions=1)
-        front_port = FrontPort.objects.create(
-            device=devices[0],
-            name='Front Port 1',
-            rear_port=rear_port,
-            rear_port_position=1
-        )
+        rear_port = RearPort.objects.create(device=devices[0], name='Rear Port 1')
+        front_port = FrontPort.objects.create(device=devices[0], name='Front Port 1')
+        PortMapping.objects.create(device=devices[0], front_port=front_port, rear_port=rear_port)
 
 
         power_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0])
         power_panel = PowerPanel.objects.create(name='Power Panel 1', site=sites[0])
         power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel)
         power_feed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel)

+ 4 - 2
netbox/dcim/tests/test_forms.py

@@ -193,7 +193,8 @@ class FrontPortTestCase(TestCase):
             'name': 'FrontPort[1-4]',
             'name': 'FrontPort[1-4]',
             'label': 'Port[1-4]',
             'label': 'Port[1-4]',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
+            'positions': 1,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
         }
         }
         form = FrontPortCreateForm(front_port_data)
         form = FrontPortCreateForm(front_port_data)
 
 
@@ -208,7 +209,8 @@ class FrontPortTestCase(TestCase):
             'name': 'FrontPort[1-4]',
             'name': 'FrontPort[1-4]',
             'label': 'Port[1-2]',
             'label': 'Port[1-2]',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
+            'positions': 1,
+            'rear_ports': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
         }
         }
         form = FrontPortCreateForm(bad_front_port_data)
         form = FrontPortCreateForm(bad_front_port_data)
 
 

+ 70 - 11
netbox/dcim/tests/test_models.py

@@ -444,13 +444,19 @@ class DeviceTestCase(TestCase):
         )
         )
         rearport.save()
         rearport.save()
 
 
-        FrontPortTemplate(
+        frontport = FrontPortTemplate(
             device_type=device_type,
             device_type=device_type,
             name='Front Port 1',
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             type=PortTypeChoices.TYPE_8P8C,
+        )
+        frontport.save()
+
+        PortTemplateMapping.objects.create(
+            device_type=device_type,
+            front_port=frontport,
             rear_port=rearport,
             rear_port=rearport,
-            rear_port_position=2
-        ).save()
+            rear_port_position=2,
+        )
 
 
         ModuleBayTemplate(
         ModuleBayTemplate(
             device_type=device_type,
             device_type=device_type,
@@ -528,11 +534,12 @@ class DeviceTestCase(TestCase):
             device=device,
             device=device,
             name='Front Port 1',
             name='Front Port 1',
             type=PortTypeChoices.TYPE_8P8C,
             type=PortTypeChoices.TYPE_8P8C,
-            rear_port=rearport,
-            rear_port_position=2
+            positions=1
         )
         )
         self.assertEqual(frontport.cf['cf1'], 'foo')
         self.assertEqual(frontport.cf['cf1'], 'foo')
 
 
+        self.assertTrue(PortMapping.objects.filter(front_port=frontport, rear_port=rearport).exists())
+
         modulebay = ModuleBay.objects.get(
         modulebay = ModuleBay.objects.get(
             device=device,
             device=device,
             name='Module Bay 1'
             name='Module Bay 1'
@@ -792,8 +799,54 @@ class ModuleBayTestCase(TestCase):
         )
         )
         device.consoleports.first()
         device.consoleports.first()
 
 
-    def test_nested_module_token(self):
-        pass
+    @tag('regression')  # #19918
+    def test_nested_module_bay_label_resolution(self):
+        """Test that nested module bay labels properly resolve {module} placeholders"""
+        manufacturer = Manufacturer.objects.first()
+        site = Site.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        # Create device type with module bay template (position='A')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Device with Bays',
+            slug='device-with-bays'
+        )
+        ModuleBayTemplate.objects.create(
+            device_type=device_type,
+            name='Bay A',
+            position='A'
+        )
+
+        # Create module type with nested bay template using {module} placeholder
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer,
+            model='Module with Nested Bays'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=module_type,
+            name='SFP {module}-21',
+            label='{module}-21',
+            position='21'
+        )
+
+        # Create device and install module
+        device = Device.objects.create(
+            name='Test Device',
+            device_type=device_type,
+            role=device_role,
+            site=site
+        )
+        module_bay = device.modulebays.get(name='Bay A')
+        module = Module.objects.create(
+            device=device,
+            module_bay=module_bay,
+            module_type=module_type
+        )
+
+        # Verify nested bay label resolves {module} to parent position
+        nested_bay = module.modulebays.get(name='SFP A-21')
+        self.assertEqual(nested_bay.label, 'A-21')
 
 
 
 
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
@@ -835,12 +888,18 @@ class CableTestCase(TestCase):
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
         front_ports = (
         front_ports = (
-            FrontPort(device=patch_panel, name='FP1', type='8p8c', rear_port=rear_ports[0], rear_port_position=1),
-            FrontPort(device=patch_panel, name='FP2', type='8p8c', rear_port=rear_ports[1], rear_port_position=1),
-            FrontPort(device=patch_panel, name='FP3', type='8p8c', rear_port=rear_ports[2], rear_port_position=1),
-            FrontPort(device=patch_panel, name='FP4', type='8p8c', rear_port=rear_ports[3], rear_port_position=1),
+            FrontPort(device=patch_panel, name='FP1', type='8p8c'),
+            FrontPort(device=patch_panel, name='FP2', type='8p8c'),
+            FrontPort(device=patch_panel, name='FP3', type='8p8c'),
+            FrontPort(device=patch_panel, name='FP4', type='8p8c'),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=patch_panel, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=patch_panel, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortMapping(device=patch_panel, front_port=front_ports[2], rear_port=rear_ports[2]),
+            PortMapping(device=patch_panel, front_port=front_ports[3], rear_port=rear_ports[3]),
+        ])
 
 
         provider = Provider.objects.create(name='Provider 1', slug='provider-1')
         provider = Provider.objects.create(name='Provider 1', slug='provider-1')
         provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
         provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)

+ 82 - 59
netbox/dcim/tests/test_views.py

@@ -741,17 +741,16 @@ class DeviceTypeTestCase(
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
         front_ports = (
         front_ports = (
-            FrontPortTemplate(
-                device_type=devicetype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1
-            ),
-            FrontPortTemplate(
-                device_type=devicetype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1
-            ),
-            FrontPortTemplate(
-                device_type=devicetype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1
-            ),
+            FrontPortTemplate(device_type=devicetype, name='Front Port 1'),
+            FrontPortTemplate(device_type=devicetype, name='Front Port 2'),
+            FrontPortTemplate(device_type=devicetype, name='Front Port 3'),
         )
         )
         FrontPortTemplate.objects.bulk_create(front_ports)
         FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -866,12 +865,16 @@ rear-ports:
 front-ports:
 front-ports:
   - name: Front Port 1
   - name: Front Port 1
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 1
   - name: Front Port 2
   - name: Front Port 2
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 2
   - name: Front Port 3
   - name: Front Port 3
     type: 8p8c
     type: 8p8c
+port-mappings:
+  - front_port: Front Port 1
+    rear_port: Rear Port 1
+  - front_port: Front Port 2
+    rear_port: Rear Port 2
+  - front_port: Front Port 3
     rear_port: Rear Port 3
     rear_port: Rear Port 3
 module-bays:
 module-bays:
   - name: Module Bay 1
   - name: Module Bay 1
@@ -971,8 +974,12 @@ inventory-items:
         self.assertEqual(device_type.frontporttemplates.count(), 3)
         self.assertEqual(device_type.frontporttemplates.count(), 3)
         fp1 = FrontPortTemplate.objects.first()
         fp1 = FrontPortTemplate.objects.first()
         self.assertEqual(fp1.name, 'Front Port 1')
         self.assertEqual(fp1.name, 'Front Port 1')
-        self.assertEqual(fp1.rear_port, rp1)
-        self.assertEqual(fp1.rear_port_position, 1)
+
+        self.assertEqual(device_type.port_mappings.count(), 3)
+        mapping1 = PortTemplateMapping.objects.first()
+        self.assertEqual(mapping1.device_type, device_type)
+        self.assertEqual(mapping1.front_port, fp1)
+        self.assertEqual(mapping1.rear_port, rp1)
 
 
         self.assertEqual(device_type.modulebaytemplates.count(), 3)
         self.assertEqual(device_type.modulebaytemplates.count(), 3)
         mb1 = ModuleBayTemplate.objects.first()
         mb1 = ModuleBayTemplate.objects.first()
@@ -1316,17 +1323,16 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         RearPortTemplate.objects.bulk_create(rear_ports)
         RearPortTemplate.objects.bulk_create(rear_ports)
         front_ports = (
         front_ports = (
-            FrontPortTemplate(
-                module_type=moduletype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1
-            ),
-            FrontPortTemplate(
-                module_type=moduletype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1
-            ),
-            FrontPortTemplate(
-                module_type=moduletype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1
-            ),
+            FrontPortTemplate(module_type=moduletype, name='Front Port 1'),
+            FrontPortTemplate(module_type=moduletype, name='Front Port 2'),
+            FrontPortTemplate(module_type=moduletype, name='Front Port 3'),
         )
         )
         FrontPortTemplate.objects.bulk_create(front_ports)
         FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(module_type=moduletype, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(module_type=moduletype, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortTemplateMapping(module_type=moduletype, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -1394,12 +1400,16 @@ rear-ports:
 front-ports:
 front-ports:
   - name: Front Port 1
   - name: Front Port 1
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 1
   - name: Front Port 2
   - name: Front Port 2
     type: 8p8c
     type: 8p8c
-    rear_port: Rear Port 2
   - name: Front Port 3
   - name: Front Port 3
     type: 8p8c
     type: 8p8c
+port-mappings:
+  - front_port: Front Port 1
+    rear_port: Rear Port 1
+  - front_port: Front Port 2
+    rear_port: Rear Port 2
+  - front_port: Front Port 3
     rear_port: Rear Port 3
     rear_port: Rear Port 3
 module-bays:
 module-bays:
   - name: Module Bay 1
   - name: Module Bay 1
@@ -1477,8 +1487,12 @@ module-bays:
         self.assertEqual(module_type.frontporttemplates.count(), 3)
         self.assertEqual(module_type.frontporttemplates.count(), 3)
         fp1 = FrontPortTemplate.objects.first()
         fp1 = FrontPortTemplate.objects.first()
         self.assertEqual(fp1.name, 'Front Port 1')
         self.assertEqual(fp1.name, 'Front Port 1')
-        self.assertEqual(fp1.rear_port, rp1)
-        self.assertEqual(fp1.rear_port_position, 1)
+
+        self.assertEqual(module_type.port_mappings.count(), 3)
+        mapping1 = PortTemplateMapping.objects.first()
+        self.assertEqual(mapping1.module_type, module_type)
+        self.assertEqual(mapping1.front_port, fp1)
+        self.assertEqual(mapping1.rear_port, rp1)
 
 
         self.assertEqual(module_type.modulebaytemplates.count(), 3)
         self.assertEqual(module_type.modulebaytemplates.count(), 3)
         mb1 = ModuleBayTemplate.objects.first()
         mb1 = ModuleBayTemplate.objects.first()
@@ -1770,7 +1784,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
 
-        rearports = (
+        rear_ports = (
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
@@ -1778,35 +1792,33 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'),
             RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'),
         )
         )
-        RearPortTemplate.objects.bulk_create(rearports)
-
-        FrontPortTemplate.objects.bulk_create(
-            (
-                FrontPortTemplate(
-                    device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1
-                ),
-                FrontPortTemplate(
-                    device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1
-                ),
-                FrontPortTemplate(
-                    device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1
-                ),
-            )
+        RearPortTemplate.objects.bulk_create(rear_ports)
+        front_ports = (
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 1'),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 2'),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 3'),
         )
         )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+        PortTemplateMapping.objects.bulk_create([
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortTemplateMapping(device_type=devicetype, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         cls.form_data = {
         cls.form_data = {
             'device_type': devicetype.pk,
             'device_type': devicetype.pk,
             'name': 'Front Port X',
             'name': 'Front Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': rearports[3].pk,
-            'rear_port_position': 1,
+            'positions': 1,
+            'rear_ports': [f'{rear_ports[3].pk}:1'],
         }
         }
 
 
         cls.bulk_create_data = {
         cls.bulk_create_data = {
             'device_type': devicetype.pk,
             'device_type': devicetype.pk,
             'name': 'Front Port [4-6]',
             'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
+            'positions': 1,
+            'rear_ports': [f'{rp.pk}:1' for rp in rear_ports[3:6]],
         }
         }
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
@@ -2276,11 +2288,16 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
         front_ports = (
         front_ports = (
-            FrontPort(device=device, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
-            FrontPort(device=device, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
-            FrontPort(device=device, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
+            FrontPort(device=device, name='Front Port Template 1'),
+            FrontPort(device=device, name='Front Port Template 2'),
+            FrontPort(device=device, name='Front Port Template 3'),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
@@ -3065,7 +3082,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
 
 
-        rearports = (
+        rear_ports = (
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 2'),
             RearPort(device=device, name='Rear Port 2'),
             RearPort(device=device, name='Rear Port 3'),
             RearPort(device=device, name='Rear Port 3'),
@@ -3073,14 +3090,19 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 5'),
             RearPort(device=device, name='Rear Port 5'),
             RearPort(device=device, name='Rear Port 6'),
             RearPort(device=device, name='Rear Port 6'),
         )
         )
-        RearPort.objects.bulk_create(rearports)
+        RearPort.objects.bulk_create(rear_ports)
 
 
         front_ports = (
         front_ports = (
-            FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]),
-            FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]),
-            FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
+            FrontPort(device=device, name='Front Port 1'),
+            FrontPort(device=device, name='Front Port 2'),
+            FrontPort(device=device, name='Front Port 3'),
         )
         )
         FrontPort.objects.bulk_create(front_ports)
         FrontPort.objects.bulk_create(front_ports)
+        PortMapping.objects.bulk_create([
+            PortMapping(device=device, front_port=front_ports[0], rear_port=rear_ports[0]),
+            PortMapping(device=device, front_port=front_ports[1], rear_port=rear_ports[1]),
+            PortMapping(device=device, front_port=front_ports[2], rear_port=rear_ports[2]),
+        ])
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
@@ -3088,8 +3110,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'device': device.pk,
             'device': device.pk,
             'name': 'Front Port X',
             'name': 'Front Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': rearports[3].pk,
-            'rear_port_position': 1,
+            'positions': 1,
+            'rear_ports': [f'{rear_ports[3].pk}:1'],
             'description': 'New description',
             'description': 'New description',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -3098,7 +3120,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             'device': device.pk,
             'device': device.pk,
             'name': 'Front Port [4-6]',
             'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
+            'positions': 1,
+            'rear_ports': [f'{rp.pk}:1' for rp in rear_ports[3:6]],
             'description': 'New description',
             'description': 'New description',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -3109,10 +3132,10 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "device,name,type,rear_port,rear_port_position",
-            "Device 1,Front Port 4,8p8c,Rear Port 4,1",
-            "Device 1,Front Port 5,8p8c,Rear Port 5,1",
-            "Device 1,Front Port 6,8p8c,Rear Port 6,1",
+            "device,name,type,positions",
+            "Device 1,Front Port 4,8p8c,1",
+            "Device 1,Front Port 5,8p8c,1",
+            "Device 1,Front Port 6,8p8c,1",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (

+ 33 - 0
netbox/dcim/utils.py

@@ -83,3 +83,36 @@ def update_interface_bridges(device, interface_templates, module=None):
             )
             )
             interface.full_clean()
             interface.full_clean()
             interface.save()
             interface.save()
+
+
+def create_port_mappings(device, device_type, module=None):
+    """
+    Replicate all front/rear port mappings from a DeviceType to the given device.
+    """
+    from dcim.models import FrontPort, PortMapping, RearPort
+
+    templates = device_type.port_mappings.prefetch_related('front_port', 'rear_port')
+
+    # Cache front & rear ports for efficient lookups by name
+    front_ports = {
+        fp.name: fp for fp in FrontPort.objects.filter(device=device)
+    }
+    rear_ports = {
+        rp.name: rp for rp in RearPort.objects.filter(device=device)
+    }
+
+    # Replicate PortMappings
+    mappings = []
+    for template in templates:
+        front_port = front_ports.get(template.front_port.resolve_name(module=module))
+        rear_port = rear_ports.get(template.rear_port.resolve_name(module=module))
+        mappings.append(
+            PortMapping(
+                device_id=front_port.device_id,
+                front_port=front_port,
+                front_port_position=template.front_port_position,
+                rear_port=rear_port,
+                rear_port_position=template.rear_port_position,
+            )
+        )
+    PortMapping.objects.bulk_create(mappings)

+ 13 - 0
netbox/dcim/views.py

@@ -42,6 +42,7 @@ from wireless.models import WirelessLAN
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .choices import DeviceFaceChoices, InterfaceModeChoices
 from .models import *
 from .models import *
+from .models.device_components import PortMapping
 from .object_actions import BulkAddComponents, BulkDisconnect
 from .object_actions import BulkAddComponents, BulkDisconnect
 
 
 CABLE_TERMINATION_TYPES = {
 CABLE_TERMINATION_TYPES = {
@@ -1515,6 +1516,7 @@ class DeviceTypeImportView(generic.BulkImportView):
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
+        'port-mappings': forms.PortTemplateMappingImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'device-bays': forms.DeviceBayTemplateImportForm,
         'device-bays': forms.DeviceBayTemplateImportForm,
         'inventory-items': forms.InventoryItemTemplateImportForm,
         'inventory-items': forms.InventoryItemTemplateImportForm,
@@ -1819,6 +1821,7 @@ class ModuleTypeImportView(generic.BulkImportView):
         'interfaces': forms.InterfaceTemplateImportForm,
         'interfaces': forms.InterfaceTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'rear-ports': forms.RearPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
         'front-ports': forms.FrontPortTemplateImportForm,
+        'port-mappings': forms.PortTemplateMappingImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
         'module-bays': forms.ModuleBayTemplateImportForm,
     }
     }
 
 
@@ -3242,6 +3245,11 @@ class FrontPortListView(generic.ObjectListView):
 class FrontPortView(generic.ObjectView):
 class FrontPortView(generic.ObjectView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+        return {
+            'rear_port_mappings': PortMapping.objects.filter(front_port=instance).prefetch_related('rear_port'),
+        }
+
 
 
 @register_model_view(FrontPort, 'add', detail=False)
 @register_model_view(FrontPort, 'add', detail=False)
 class FrontPortCreateView(generic.ComponentCreateView):
 class FrontPortCreateView(generic.ComponentCreateView):
@@ -3313,6 +3321,11 @@ class RearPortListView(generic.ObjectListView):
 class RearPortView(generic.ObjectView):
 class RearPortView(generic.ObjectView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
 
 
+    def get_extra_context(self, request, instance):
+        return {
+            'front_port_mappings': PortMapping.objects.filter(rear_port=instance).prefetch_related('front_port'),
+        }
+
 
 
 @register_model_view(RearPort, 'add', detail=False)
 @register_model_view(RearPort, 'add', detail=False)
 class RearPortCreateView(generic.ComponentCreateView):
 class RearPortCreateView(generic.ComponentCreateView):

+ 4 - 14
netbox/extras/graphql/filter_mixins.py

@@ -3,9 +3,6 @@ from typing import Annotated, TYPE_CHECKING
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from strawberry_django import FilterLookup
-
-from core.graphql.filter_mixins import BaseFilterMixin
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from netbox.graphql.filter_lookups import JSONFilter
     from netbox.graphql.filter_lookups import JSONFilter
@@ -16,37 +13,30 @@ __all__ = (
     'JournalEntriesFilterMixin',
     'JournalEntriesFilterMixin',
     'TagsFilterMixin',
     'TagsFilterMixin',
     'ConfigContextFilterMixin',
     'ConfigContextFilterMixin',
-    'TagBaseFilterMixin',
 )
 )
 
 
 
 
 @dataclass
 @dataclass
-class CustomFieldsFilterMixin(BaseFilterMixin):
+class CustomFieldsFilterMixin:
     custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     custom_field_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @dataclass
 @dataclass
-class JournalEntriesFilterMixin(BaseFilterMixin):
+class JournalEntriesFilterMixin:
     journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     journal_entries: Annotated['JournalEntryFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @dataclass
 @dataclass
-class TagsFilterMixin(BaseFilterMixin):
+class TagsFilterMixin:
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 
 
 @dataclass
 @dataclass
-class ConfigContextFilterMixin(BaseFilterMixin):
+class ConfigContextFilterMixin:
     local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     local_context_data: Annotated['JSONFilter', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
-
-
-@dataclass
-class TagBaseFilterMixin(BaseFilterMixin):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()

+ 20 - 18
netbox/extras/graphql/filters.py

@@ -5,10 +5,10 @@ import strawberry_django
 from strawberry.scalars import ID
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup
 
 
-from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 from extras import models
 from extras import models
-from extras.graphql.filter_mixins import TagBaseFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin
-from netbox.graphql.filter_mixins import PrimaryModelFilterMixin, SyncedDataFilterMixin
+from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
+from netbox.graphql.filter_mixins import SyncedDataFilterMixin
+from netbox.graphql.filters import ChangeLoggedModelFilter, PrimaryModelFilter
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
@@ -42,7 +42,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.ConfigContext, lookups=True)
 @strawberry_django.filter_type(models.ConfigContext, lookups=True)
-class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+class ConfigContextFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -99,14 +99,14 @@ class ConfigContextFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Chan
 
 
 
 
 @strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
 @strawberry_django.filter_type(models.ConfigContextProfile, lookups=True)
-class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilterMixin):
+class ConfigContextProfileFilter(SyncedDataFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] = strawberry_django.filter_field()
     name: FilterLookup[str] = strawberry_django.filter_field()
     description: FilterLookup[str] = strawberry_django.filter_field()
     description: FilterLookup[str] = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
     tags: Annotated['TagFilter', strawberry.lazy('extras.graphql.filters')] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
 @strawberry_django.filter_type(models.ConfigTemplate, lookups=True)
-class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+class ConfigTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -120,7 +120,7 @@ class ConfigTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
 
 
 
 
 @strawberry_django.filter_type(models.CustomField, lookups=True)
 @strawberry_django.filter_type(models.CustomField, lookups=True)
-class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class CustomFieldFilter(ChangeLoggedModelFilter):
     type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['CustomFieldTypeEnum', strawberry.lazy('extras.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -179,7 +179,7 @@ class CustomFieldFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
 @strawberry_django.filter_type(models.CustomFieldChoiceSet, lookups=True)
-class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class CustomFieldChoiceSetFilter(ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     base_choices: (
     base_choices: (
@@ -194,7 +194,7 @@ class CustomFieldChoiceSetFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 
 
 
 
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
 @strawberry_django.filter_type(models.CustomLink, lookups=True)
-class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class CustomLinkFilter(ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     link_text: FilterLookup[str] | None = strawberry_django.filter_field()
     link_text: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -212,7 +212,7 @@ class CustomLinkFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
 @strawberry_django.filter_type(models.ExportTemplate, lookups=True)
-class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, ChangeLogFilterMixin):
+class ExportTemplateFilter(SyncedDataFilterMixin, ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: FilterLookup[str] | None = strawberry_django.filter_field()
     template_code: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -226,7 +226,7 @@ class ExportTemplateFilter(BaseObjectTypeFilterMixin, SyncedDataFilterMixin, Cha
 
 
 
 
 @strawberry_django.filter_type(models.ImageAttachment, lookups=True)
 @strawberry_django.filter_type(models.ImageAttachment, lookups=True)
-class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class ImageAttachmentFilter(ChangeLoggedModelFilter):
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -241,7 +241,7 @@ class ImageAttachmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.JournalEntry, lookups=True)
 @strawberry_django.filter_type(models.JournalEntry, lookups=True)
-class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     assigned_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -257,7 +257,7 @@ class JournalEntryFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, Tag
 
 
 
 
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
-class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class NotificationGroupFilter(ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     groups: Annotated['GroupFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -265,7 +265,7 @@ class NotificationGroupFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.SavedFilter, lookups=True)
 @strawberry_django.filter_type(models.SavedFilter, lookups=True)
-class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class SavedFilterFilter(ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -282,7 +282,7 @@ class SavedFilterFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
-class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class TableConfigFilter(ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
     user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
@@ -295,7 +295,9 @@ class TableConfigFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Tag, lookups=True)
 @strawberry_django.filter_type(models.Tag, lookups=True)
-class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMixin):
+class TagFilter(ChangeLoggedModelFilter):
+    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    slug: FilterLookup[str] | None = strawberry_django.filter_field()
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -303,7 +305,7 @@ class TagFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin, TagBaseFilterMi
 
 
 
 
 @strawberry_django.filter_type(models.Webhook, lookups=True)
 @strawberry_django.filter_type(models.Webhook, lookups=True)
-class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+class WebhookFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
     payload_url: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -324,7 +326,7 @@ class WebhookFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilt
 
 
 
 
 @strawberry_django.filter_type(models.EventRule, lookups=True)
 @strawberry_django.filter_type(models.EventRule, lookups=True)
-class EventRuleFilter(BaseObjectTypeFilterMixin, CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+class EventRuleFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     event_types: Annotated['StringArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (

+ 25 - 11
netbox/extras/jobs.py

@@ -2,11 +2,14 @@ import logging
 import traceback
 import traceback
 from contextlib import ExitStack
 from contextlib import ExitStack
 
 
-from django.db import transaction
+from django.db import router, transaction
+from django.db import DEFAULT_DB_ALIAS
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
 from core.signals import clear_events
 from core.signals import clear_events
+from dcim.models import Device
 from extras.models import Script as ScriptModel
 from extras.models import Script as ScriptModel
+from netbox.context_managers import event_tracking
 from netbox.jobs import JobRunner
 from netbox.jobs import JobRunner
 from netbox.registry import registry
 from netbox.registry import registry
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.exceptions import AbortScript, AbortTransaction
@@ -42,10 +45,21 @@ class ScriptJob(JobRunner):
                 # A script can modify multiple models so need to do an atomic lock on
                 # A script can modify multiple models so need to do an atomic lock on
                 # both the default database (for non ChangeLogged models) and potentially
                 # both the default database (for non ChangeLogged models) and potentially
                 # any other database (for ChangeLogged models)
                 # any other database (for ChangeLogged models)
-                with transaction.atomic():
-                    script.output = script.run(data, commit)
-                    if not commit:
-                        raise AbortTransaction()
+                changeloged_db = router.db_for_write(Device)
+                with transaction.atomic(using=DEFAULT_DB_ALIAS):
+                    # If branch database is different from default, wrap in a second atomic transaction
+                    # Note: Don't add any extra code between the two atomic transactions,
+                    # otherwise the changes might get committed to the default database
+                    # if there are any raised exceptions.
+                    if changeloged_db != DEFAULT_DB_ALIAS:
+                        with transaction.atomic(using=changeloged_db):
+                            script.output = script.run(data, commit)
+                            if not commit:
+                                raise AbortTransaction()
+                    else:
+                        script.output = script.run(data, commit)
+                        if not commit:
+                            raise AbortTransaction()
             except AbortTransaction:
             except AbortTransaction:
                 script.log_info(message=_("Database changes have been reverted automatically."))
                 script.log_info(message=_("Database changes have been reverted automatically."))
                 if script.failed:
                 if script.failed:
@@ -108,14 +122,14 @@ class ScriptJob(JobRunner):
         script.request = request
         script.request = request
         self.logger.debug(f"Request ID: {request.id if request else None}")
         self.logger.debug(f"Request ID: {request.id if request else None}")
 
 
-        # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
-        # change logging, event rules, etc.
         if commit:
         if commit:
             self.logger.info("Executing script (commit enabled)")
             self.logger.info("Executing script (commit enabled)")
-            with ExitStack() as stack:
-                for request_processor in registry['request_processors']:
-                    stack.enter_context(request_processor(request))
-                self.run_script(script, request, data, commit)
         else:
         else:
             self.logger.warning("Executing script (commit disabled)")
             self.logger.warning("Executing script (commit disabled)")
+
+        with ExitStack() as stack:
+            for request_processor in registry['request_processors']:
+                if not commit and request_processor is event_tracking:
+                    continue
+                stack.enter_context(request_processor(request))
             self.run_script(script, request, data, commit)
             self.run_script(script, request, data, commit)

+ 4 - 5
netbox/ipam/graphql/filter_mixins.py

@@ -3,21 +3,20 @@ from typing import Annotated, TYPE_CHECKING
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-
-from core.graphql.filter_mixins import BaseFilterMixin
+from strawberry_django import BaseFilterLookup
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from netbox.graphql.filter_lookups import IntegerLookup
     from netbox.graphql.filter_lookups import IntegerLookup
     from .enums import *
     from .enums import *
 
 
 __all__ = (
 __all__ = (
-    'ServiceBaseFilterMixin',
+    'ServiceFilterMixin',
 )
 )
 
 
 
 
 @dataclass
 @dataclass
-class ServiceBaseFilterMixin(BaseFilterMixin):
-    protocol: Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')] | None = (
+class ServiceFilterMixin:
+    protocol: BaseFilterLookup[Annotated['ServiceProtocolEnum', strawberry.lazy('ipam.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
     ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     ports: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (

+ 22 - 21
netbox/ipam/graphql/filters.py

@@ -9,12 +9,13 @@ from netaddr.core import AddrFormatError
 from strawberry.scalars import ID
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup, DateFilterLookup
 
 
-from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
 from dcim.graphql.filter_mixins import ScopedFilterMixin
 from dcim.graphql.filter_mixins import ScopedFilterMixin
 from dcim.models import Device
 from dcim.models import Device
 from ipam import models
 from ipam import models
-from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
-from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
+from ipam.graphql.filter_mixins import ServiceFilterMixin
+from netbox.graphql.filters import (
+    ChangeLoggedModelFilter, NetBoxModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
+)
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 
 
@@ -49,7 +50,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.ASN, lookups=True)
 @strawberry_django.filter_type(models.ASN, lookups=True)
-class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class ASNFilter(TenancyFilterMixin, PrimaryModelFilter):
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     asn: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -64,7 +65,7 @@ class ASNFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ASNRange, lookups=True)
 @strawberry_django.filter_type(models.ASNRange, lookups=True)
-class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
+class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
@@ -78,7 +79,7 @@ class ASNRangeFilter(TenancyFilterMixin, OrganizationalModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Aggregate, lookups=True)
 @strawberry_django.filter_type(models.Aggregate, lookups=True)
-class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     prefix: FilterLookup[str] | None = strawberry_django.filter_field()
     prefix: FilterLookup[str] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir: Annotated['RIRFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
     rir_id: ID | None = strawberry_django.filter_field()
@@ -111,7 +112,7 @@ class AggregateFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
 
 
 
 
 @strawberry_django.filter_type(models.FHRPGroup, lookups=True)
 @strawberry_django.filter_type(models.FHRPGroup, lookups=True)
-class FHRPGroupFilter(PrimaryModelFilterMixin):
+class FHRPGroupFilter(PrimaryModelFilter):
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     group_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -129,7 +130,7 @@ class FHRPGroupFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
 @strawberry_django.filter_type(models.FHRPGroupAssignment, lookups=True)
-class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin):
+class FHRPGroupAssignmentFilter(ChangeLoggedModelFilter):
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     interface_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -168,7 +169,7 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
 
 
 
 
 @strawberry_django.filter_type(models.IPAddress, lookups=True)
 @strawberry_django.filter_type(models.IPAddress, lookups=True)
-class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     address: FilterLookup[str] | None = strawberry_django.filter_field()
     address: FilterLookup[str] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
@@ -219,7 +220,7 @@ class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter
 
 
 
 
 @strawberry_django.filter_type(models.IPRange, lookups=True)
 @strawberry_django.filter_type(models.IPRange, lookups=True)
-class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     start_address: FilterLookup[str] | None = strawberry_django.filter_field()
     start_address: FilterLookup[str] | None = strawberry_django.filter_field()
     end_address: FilterLookup[str] | None = strawberry_django.filter_field()
     end_address: FilterLookup[str] | None = strawberry_django.filter_field()
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     size: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
@@ -273,7 +274,7 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi
 
 
 
 
 @strawberry_django.filter_type(models.Prefix, lookups=True)
 @strawberry_django.filter_type(models.Prefix, lookups=True)
-class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
+class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilter):
     prefix: FilterLookup[str] | None = strawberry_django.filter_field()
     prefix: FilterLookup[str] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
     vrf_id: ID | None = strawberry_django.filter_field()
@@ -310,19 +311,19 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr
 
 
 
 
 @strawberry_django.filter_type(models.RIR, lookups=True)
 @strawberry_django.filter_type(models.RIR, lookups=True)
-class RIRFilter(OrganizationalModelFilterMixin):
+class RIRFilter(OrganizationalModelFilter):
     is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
     is_private: FilterLookup[bool] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.Role, lookups=True)
 @strawberry_django.filter_type(models.Role, lookups=True)
-class RoleFilter(OrganizationalModelFilterMixin):
+class RoleFilter(OrganizationalModelFilter):
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     weight: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.RouteTarget, lookups=True)
 @strawberry_django.filter_type(models.RouteTarget, lookups=True)
-class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     importing_vrfs: Annotated['VRFFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -339,7 +340,7 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Service, lookups=True)
 @strawberry_django.filter_type(models.Service, lookups=True)
-class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+class ServiceFilter(ContactFilterMixin, ServiceFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     ip_addresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -351,12 +352,12 @@ class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilt
 
 
 
 
 @strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
 @strawberry_django.filter_type(models.ServiceTemplate, lookups=True)
-class ServiceTemplateFilter(ServiceBaseFilterMixin, PrimaryModelFilterMixin):
+class ServiceTemplateFilter(ServiceFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.VLAN, lookups=True)
 @strawberry_django.filter_type(models.VLAN, lookups=True)
-class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class VLANFilter(TenancyFilterMixin, PrimaryModelFilter):
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     site_id: ID | None = strawberry_django.filter_field()
     group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     group: Annotated['VLANGroupFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
@@ -388,19 +389,19 @@ class VLANFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VLANGroup, lookups=True)
 @strawberry_django.filter_type(models.VLANGroup, lookups=True)
-class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilterMixin):
+class VLANGroupFilter(ScopedFilterMixin, OrganizationalModelFilter):
     vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     vid_ranges: Annotated['IntegerRangeArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
 @strawberry_django.filter_type(models.VLANTranslationPolicy, lookups=True)
-class VLANTranslationPolicyFilter(PrimaryModelFilterMixin):
+class VLANTranslationPolicyFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
 @strawberry_django.filter_type(models.VLANTranslationRule, lookups=True)
-class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
+class VLANTranslationRuleFilter(NetBoxModelFilter):
     policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
     policy: Annotated['VLANTranslationPolicyFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -415,7 +416,7 @@ class VLANTranslationRuleFilter(NetBoxModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.VRF, lookups=True)
 @strawberry_django.filter_type(models.VRF, lookups=True)
-class VRFFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
+class VRFFilter(TenancyFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     rd: FilterLookup[str] | None = strawberry_django.filter_field()
     rd: FilterLookup[str] | None = strawberry_django.filter_field()
     enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()
     enforce_unique: FilterLookup[bool] | None = strawberry_django.filter_field()

+ 4 - 49
netbox/netbox/graphql/filter_mixins.py

@@ -4,19 +4,11 @@ from typing import TypeVar, TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from strawberry import ID
 from strawberry_django import BaseFilterLookup, FilterLookup, DatetimeFilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup, DatetimeFilterLookup
 
 
-from core.graphql.filter_mixins import BaseFilterMixin, BaseObjectTypeFilterMixin, ChangeLogFilterMixin
-from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
-
 __all__ = (
 __all__ = (
     'DistanceFilterMixin',
     'DistanceFilterMixin',
     'ImageAttachmentFilterMixin',
     'ImageAttachmentFilterMixin',
-    'NestedGroupModelFilterMixin',
-    'NetBoxModelFilterMixin',
-    'OrganizationalModelFilterMixin',
-    'PrimaryModelFilterMixin',
     'SyncedDataFilterMixin',
     'SyncedDataFilterMixin',
     'WeightFilterMixin',
     'WeightFilterMixin',
 )
 )
@@ -30,52 +22,15 @@ if TYPE_CHECKING:
     from extras.graphql.filters import *
     from extras.graphql.filters import *
 
 
 
 
-class NetBoxModelFilterMixin(
-    ChangeLogFilterMixin,
-    CustomFieldsFilterMixin,
-    JournalEntriesFilterMixin,
-    TagsFilterMixin,
-    BaseObjectTypeFilterMixin,
-):
-    pass
-
-
-@dataclass
-class NestedGroupModelFilterMixin(NetBoxModelFilterMixin):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    parent_id: ID | None = strawberry_django.filter_field()
-
-
-@dataclass
-class OrganizationalModelFilterMixin(
-    ChangeLogFilterMixin,
-    CustomFieldsFilterMixin,
-    TagsFilterMixin,
-    BaseObjectTypeFilterMixin,
-):
-    name: FilterLookup[str] | None = strawberry_django.filter_field()
-    slug: FilterLookup[str] | None = strawberry_django.filter_field()
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
-
-
-@dataclass
-class PrimaryModelFilterMixin(NetBoxModelFilterMixin):
-    description: FilterLookup[str] | None = strawberry_django.filter_field()
-    comments: FilterLookup[str] | None = strawberry_django.filter_field()
-
-
 @dataclass
 @dataclass
-class ImageAttachmentFilterMixin(BaseFilterMixin):
+class ImageAttachmentFilterMixin:
     images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
     images: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @dataclass
 @dataclass
-class WeightFilterMixin(BaseFilterMixin):
+class WeightFilterMixin:
     weight: FilterLookup[float] | None = strawberry_django.filter_field()
     weight: FilterLookup[float] | None = strawberry_django.filter_field()
     weight_unit: BaseFilterLookup[Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     weight_unit: BaseFilterLookup[Annotated['WeightUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -83,7 +38,7 @@ class WeightFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class SyncedDataFilterMixin(BaseFilterMixin):
+class SyncedDataFilterMixin:
     data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
     data_source: Annotated['DataSourceFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -98,7 +53,7 @@ class SyncedDataFilterMixin(BaseFilterMixin):
 
 
 
 
 @dataclass
 @dataclass
-class DistanceFilterMixin(BaseFilterMixin):
+class DistanceFilterMixin:
     distance: FilterLookup[float] | None = strawberry_django.filter_field()
     distance: FilterLookup[float] | None = strawberry_django.filter_field()
     distance_unit: BaseFilterLookup[Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
     distance_unit: BaseFilterLookup[Annotated['DistanceUnitEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()

+ 62 - 0
netbox/netbox/graphql/filters.py

@@ -0,0 +1,62 @@
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+import strawberry_django
+from strawberry import ID
+from strawberry_django import FilterLookup
+
+from core.graphql.filter_mixins import ChangeLoggingMixin
+from extras.graphql.filter_mixins import CustomFieldsFilterMixin, JournalEntriesFilterMixin, TagsFilterMixin
+
+if TYPE_CHECKING:
+    from .filters import *
+
+__all__ = (
+    'BaseModelFilter',
+    'ChangeLoggedModelFilter',
+    'NestedGroupModelFilter',
+    'NetBoxModelFilter',
+    'OrganizationalModelFilter',
+    'PrimaryModelFilter',
+)
+
+
+@dataclass
+class BaseModelFilter:
+    id: FilterLookup[ID] | None = strawberry_django.filter_field()
+
+
+class ChangeLoggedModelFilter(ChangeLoggingMixin, BaseModelFilter):
+    pass
+
+
+class NetBoxModelFilter(
+    CustomFieldsFilterMixin,
+    JournalEntriesFilterMixin,
+    TagsFilterMixin,
+    ChangeLoggingMixin,
+    BaseModelFilter
+):
+    pass
+
+
+@dataclass
+class NestedGroupModelFilter(NetBoxModelFilter):
+    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    parent_id: ID | None = strawberry_django.filter_field()
+
+
+@dataclass
+class OrganizationalModelFilter(NetBoxModelFilter):
+    name: FilterLookup[str] | None = strawberry_django.filter_field()
+    slug: FilterLookup[str] | None = strawberry_django.filter_field()
+    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
+@dataclass
+class PrimaryModelFilter(NetBoxModelFilter):
+    description: FilterLookup[str] | None = strawberry_django.filter_field()
+    comments: FilterLookup[str] | None = strawberry_django.filter_field()

+ 7 - 3
netbox/netbox/views/generic/object_views.py

@@ -1,6 +1,5 @@
 import logging
 import logging
 from collections import defaultdict
 from collections import defaultdict
-from copy import deepcopy
 
 
 from django.contrib import messages
 from django.contrib import messages
 from django.db import router, transaction
 from django.db import router, transaction
@@ -562,8 +561,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         form.instance._replicated_base = hasattr(self.form, "replication_fields")
         form.instance._replicated_base = hasattr(self.form, "replication_fields")
 
 
         if form.is_valid():
         if form.is_valid():
+            changelog_message = form.cleaned_data.pop('changelog_message', '')
             new_components = []
             new_components = []
-            data = deepcopy(request.POST)
+            data = request.POST.copy()
             pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
             pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
 
 
             for i in range(pattern_count):
             for i in range(pattern_count):
@@ -572,7 +572,8 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         data[field_name] = form.cleaned_data[field_name][i]
                         data[field_name] = form.cleaned_data[field_name][i]
 
 
                 if hasattr(form, 'get_iterative_data'):
                 if hasattr(form, 'get_iterative_data'):
-                    data.update(form.get_iterative_data(i))
+                    for k, v in form.get_iterative_data(i).items():
+                        data.setlist(k, v)
 
 
                 component_form = self.model_form(data)
                 component_form = self.model_form(data)
 
 
@@ -588,6 +589,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         # Create the new components
                         # Create the new components
                         new_objs = []
                         new_objs = []
                         for component_form in new_components:
                         for component_form in new_components:
+                            # Record changelog message (if any)
+                            if changelog_message:
+                                component_form.instance._changelog_message = changelog_message
                             obj = component_form.save()
                             obj = component_form.save()
                             new_objs.append(obj)
                             new_objs.append(obj)
 
 

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 1 - 1
netbox/project-static/package.json

@@ -31,7 +31,7 @@
     "gridstack": "12.3.3",
     "gridstack": "12.3.3",
     "htmx.org": "2.0.8",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
     "query-string": "9.3.1",
-    "sass": "1.94.2",
+    "sass": "1.95.0",
     "tom-select": "2.4.3",
     "tom-select": "2.4.3",
     "typeface-inter": "3.18.1",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
     "typeface-roboto-mono": "1.1.13"

+ 52 - 11
netbox/project-static/src/buttons/moveOptions.ts

@@ -1,7 +1,7 @@
 import { getElements } from '../util';
 import { getElements } from '../util';
 
 
 /**
 /**
- * Move selected options from one select element to another.
+ * Move selected options from one select element to another, preserving optgroup structure.
  *
  *
  * @param source Select Element
  * @param source Select Element
  * @param target Select Element
  * @param target Select Element
@@ -9,14 +9,42 @@ import { getElements } from '../util';
 function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
 function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void {
   for (const option of Array.from(source.options)) {
   for (const option of Array.from(source.options)) {
     if (option.selected) {
     if (option.selected) {
-      target.appendChild(option.cloneNode(true));
+      // Check if option is inside an optgroup
+      const parentOptgroup = option.parentElement as HTMLElement;
+
+      if (parentOptgroup.tagName === 'OPTGROUP') {
+        // Find or create matching optgroup in target
+        const groupLabel = parentOptgroup.getAttribute('label');
+        let targetOptgroup = Array.from(target.children).find(
+          child => child.tagName === 'OPTGROUP' && child.getAttribute('label') === groupLabel,
+        ) as HTMLOptGroupElement;
+
+        if (!targetOptgroup) {
+          // Create new optgroup in target
+          targetOptgroup = document.createElement('optgroup');
+          targetOptgroup.setAttribute('label', groupLabel!);
+          target.appendChild(targetOptgroup);
+        }
+
+        // Move option to target optgroup
+        targetOptgroup.appendChild(option.cloneNode(true));
+      } else {
+        // Option is not in an optgroup, append directly
+        target.appendChild(option.cloneNode(true));
+      }
+
       option.remove();
       option.remove();
+
+      // Clean up empty optgroups in source
+      if (parentOptgroup.tagName === 'OPTGROUP' && parentOptgroup.children.length === 0) {
+        parentOptgroup.remove();
+      }
     }
     }
   }
   }
 }
 }
 
 
 /**
 /**
- * Move selected options of a select element up in order.
+ * Move selected options of a select element up in order, respecting optgroup boundaries.
  *
  *
  * Adapted from:
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -27,14 +55,21 @@ function moveOptionUp(element: HTMLSelectElement): void {
   for (let i = 1; i < options.length; i++) {
   for (let i = 1; i < options.length; i++) {
     const option = options[i];
     const option = options[i];
     if (option.selected) {
     if (option.selected) {
-      element.removeChild(option);
-      element.insertBefore(option, element.options[i - 1]);
+      const parent = option.parentElement as HTMLElement;
+      const previousOption = element.options[i - 1];
+      const previousParent = previousOption.parentElement as HTMLElement;
+
+      // Only move if previous option is in the same parent (optgroup or select)
+      if (parent === previousParent) {
+        parent.removeChild(option);
+        parent.insertBefore(option, previousOption);
+      }
     }
     }
   }
   }
 }
 }
 
 
 /**
 /**
- * Move selected options of a select element down in order.
+ * Move selected options of a select element down in order, respecting optgroup boundaries.
  *
  *
  * Adapted from:
  * Adapted from:
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
  * @see https://www.tomred.net/css-html-js/reorder-option-elements-of-an-html-select.html
@@ -43,12 +78,18 @@ function moveOptionUp(element: HTMLSelectElement): void {
 function moveOptionDown(element: HTMLSelectElement): void {
 function moveOptionDown(element: HTMLSelectElement): void {
   const options = Array.from(element.options);
   const options = Array.from(element.options);
   for (let i = options.length - 2; i >= 0; i--) {
   for (let i = options.length - 2; i >= 0; i--) {
-    let option = options[i];
+    const option = options[i];
     if (option.selected) {
     if (option.selected) {
-      let next = element.options[i + 1];
-      option = element.removeChild(option);
-      next = element.replaceChild(option, next);
-      element.insertBefore(next, option);
+      const parent = option.parentElement as HTMLElement;
+      const nextOption = element.options[i + 1];
+      const nextParent = nextOption.parentElement as HTMLElement;
+
+      // Only move if next option is in the same parent (optgroup or select)
+      if (parent === nextParent) {
+        const optionClone = parent.removeChild(option);
+        const nextClone = parent.replaceChild(optionClone, nextOption);
+        parent.insertBefore(nextClone, optionClone);
+      }
     }
     }
   }
   }
 }
 }

+ 14 - 0
netbox/project-static/styles/transitional/_forms.scss

@@ -33,6 +33,20 @@ form.object-edit {
   }
   }
 }
 }
 
 
+// Make optgroup labels sticky when scrolling through select elements
+select[multiple] {
+  optgroup {
+    position: sticky;
+    top: 0;
+    background-color: var(--bs-body-bg);
+    font-style: normal;
+    font-weight: bold;
+  }
+  option {
+    padding-left: 0.5rem;
+  }
+}
+
 // Filter modifier dropdown sizing
 // Filter modifier dropdown sizing
 .modifier-select {
 .modifier-select {
   min-width: 10rem;
   min-width: 10rem;

+ 4 - 4
netbox/project-static/yarn.lock

@@ -3251,10 +3251,10 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     es-errors "^1.3.0"
     is-regex "^1.2.1"
     is-regex "^1.2.1"
 
 
-sass@1.94.2:
-  version "1.94.2"
-  resolved "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz"
-  integrity sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==
+sass@1.95.0:
+  version "1.95.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
+  integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
   dependencies:
   dependencies:
     chokidar "^4.0.0"
     chokidar "^4.0.0"
     immutable "^5.0.2"
     immutable "^5.0.2"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.4.7"
+version: "4.4.8"
 edition: "Community"
 edition: "Community"
-published: "2025-11-25"
+published: "2025-12-09"

+ 26 - 7
netbox/templates/dcim/frontport.html

@@ -47,12 +47,8 @@
                       </td>
                       </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
-                        <th scope="row">{% trans "Rear Port" %}</th>
-                        <td>{{ object.rear_port|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Rear Port Position" %}</th>
-                        <td>{{ object.rear_port_position }}</td>
+                        <th scope="row">{% trans "Positions" %}</th>
+                        <td>{{ object.positions }}</td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">{% trans "Description" %}</th>
                         <th scope="row">{% trans "Description" %}</th>
@@ -62,6 +58,7 @@
             </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/tags.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-12 col-md-6">
         <div class="col col-12 col-md-6">
@@ -126,7 +123,29 @@
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
+            <div class="card">
+              <h2 class="card-header">{% trans "Port Mappings" %}</h2>
+              <table class="table table-hover">
+                {% if rear_port_mappings %}
+                  <thead>
+                    <tr>
+                      <th>{% trans "Position" %}</th>
+                      <th>{% trans "Rear Port" %}</th>
+                    </tr>
+                  </thead>
+                {% endif %}
+                {% for mapping in rear_port_mappings %}
+                  <tr>
+                    <td>{{ mapping.front_port_position }}</td>
+                    <td>
+                      <a href="{{ mapping.rear_port.get_absolute_url }}">{{ mapping.rear_port }}:{{ mapping.rear_port_position }}</a>
+                    </td>
+                  </tr>
+                {% empty %}
+                  {% trans "No mappings defined" %}
+                {% endfor %}
+              </table>
+            </div>
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 24 - 1
netbox/templates/dcim/rearport.html

@@ -58,6 +58,7 @@
             </div>
             </div>
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/custom_fields.html' %}
             {% include 'inc/panels/tags.html' %}
             {% include 'inc/panels/tags.html' %}
+            {% include 'dcim/inc/panels/inventory_items.html' %}
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-12 col-md-6">
         <div class="col col-12 col-md-6">
@@ -116,7 +117,29 @@
                     </div>
                     </div>
                 {% endif %}
                 {% endif %}
             </div>
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
+            <div class="card">
+              <h2 class="card-header">{% trans "Port Mappings" %}</h2>
+              <table class="table table-hover">
+                {% if front_port_mappings %}
+                  <thead>
+                    <tr>
+                      <th>{% trans "Position" %}</th>
+                      <th>{% trans "Front Port" %}</th>
+                    </tr>
+                  </thead>
+                {% endif %}
+                {% for mapping in front_port_mappings %}
+                  <tr>
+                    <td>{{ mapping.rear_port_position }}</td>
+                    <td>
+                      <a href="{{ mapping.front_port.get_absolute_url }}">{{ mapping.front_port }}:{{ mapping.front_port_position }}</a>
+                    </td>
+                  </tr>
+                {% empty %}
+                  {% trans "No mappings defined" %}
+                {% endfor %}
+              </table>
+            </div>
             {% plugin_right_page object %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

+ 2 - 4
netbox/tenancy/graphql/filter_mixins.py

@@ -5,8 +5,6 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from strawberry import ID
 from strawberry import ID
 
 
-from core.graphql.filter_mixins import BaseFilterMixin
-
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from netbox.graphql.filter_lookups import TreeNodeFilter
     from netbox.graphql.filter_lookups import TreeNodeFilter
     from .filters import ContactAssignmentFilter, TenantFilter, TenantGroupFilter
     from .filters import ContactAssignmentFilter, TenantFilter, TenantGroupFilter
@@ -18,14 +16,14 @@ __all__ = (
 
 
 
 
 @dataclass
 @dataclass
-class ContactFilterMixin(BaseFilterMixin):
+class ContactFilterMixin:
     contacts: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
     contacts: Annotated['ContactAssignmentFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @dataclass
 @dataclass
-class TenancyFilterMixin(BaseFilterMixin):
+class TenancyFilterMixin:
     tenant: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
     tenant: Annotated['TenantFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 8 - 11
netbox/tenancy/graphql/filters.py

@@ -5,12 +5,9 @@ import strawberry_django
 from strawberry.scalars import ID
 from strawberry.scalars import ID
 from strawberry_django import BaseFilterLookup, FilterLookup
 from strawberry_django import BaseFilterLookup, FilterLookup
 
 
-from core.graphql.filter_mixins import ChangeLogFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
-from netbox.graphql.filter_mixins import (
-    NestedGroupModelFilterMixin,
-    OrganizationalModelFilterMixin,
-    PrimaryModelFilterMixin,
+from netbox.graphql.filters import (
+    ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
 )
 )
 from tenancy import models
 from tenancy import models
 from .filter_mixins import ContactFilterMixin
 from .filter_mixins import ContactFilterMixin
@@ -57,7 +54,7 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.Tenant, lookups=True)
 @strawberry_django.filter_type(models.Tenant, lookups=True)
-class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
+class TenantFilter(ContactFilterMixin, PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     slug: FilterLookup[str] | None = strawberry_django.filter_field()
     group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
     group: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
@@ -136,7 +133,7 @@ class TenantFilter(PrimaryModelFilterMixin, ContactFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.TenantGroup, lookups=True)
 @strawberry_django.filter_type(models.TenantGroup, lookups=True)
-class TenantGroupFilter(OrganizationalModelFilterMixin):
+class TenantGroupFilter(OrganizationalModelFilter):
     parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
     parent: Annotated['TenantGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
@@ -150,7 +147,7 @@ class TenantGroupFilter(OrganizationalModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Contact, lookups=True)
 @strawberry_django.filter_type(models.Contact, lookups=True)
-class ContactFilter(PrimaryModelFilterMixin):
+class ContactFilter(PrimaryModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     title: FilterLookup[str] | None = strawberry_django.filter_field()
     title: FilterLookup[str] | None = strawberry_django.filter_field()
     phone: FilterLookup[str] | None = strawberry_django.filter_field()
     phone: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -166,19 +163,19 @@ class ContactFilter(PrimaryModelFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.ContactRole, lookups=True)
 @strawberry_django.filter_type(models.ContactRole, lookups=True)
-class ContactRoleFilter(OrganizationalModelFilterMixin):
+class ContactRoleFilter(OrganizationalModelFilter):
     pass
     pass
 
 
 
 
 @strawberry_django.filter_type(models.ContactGroup, lookups=True)
 @strawberry_django.filter_type(models.ContactGroup, lookups=True)
-class ContactGroupFilter(NestedGroupModelFilterMixin):
+class ContactGroupFilter(NestedGroupModelFilter):
     parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
     parent: Annotated['ContactGroupFilter', strawberry.lazy('tenancy.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
 
 
 
 
 @strawberry_django.filter_type(models.ContactAssignment, lookups=True)
 @strawberry_django.filter_type(models.ContactAssignment, lookups=True)
-class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLogFilterMixin):
+class ContactAssignmentFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedModelFilter):
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
     object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 189 - 189
netbox/translations/de/LC_MESSAGES/django.po


Fișier diff suprimat deoarece este prea mare
+ 192 - 192
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/it/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/it/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 192 - 192
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/nl/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/nl/LC_MESSAGES/django.po


BIN
netbox/translations/pl/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/pl/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 192 - 192
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 190 - 190
netbox/translations/tr/LC_MESSAGES/django.po


BIN
netbox/translations/uk/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 192 - 192
netbox/translations/uk/LC_MESSAGES/django.po


BIN
netbox/translations/zh/LC_MESSAGES/django.mo


Fișier diff suprimat deoarece este prea mare
+ 192 - 192
netbox/translations/zh/LC_MESSAGES/django.po


+ 21 - 4
netbox/users/forms/model_forms.py

@@ -1,6 +1,8 @@
 import json
 import json
+from collections import defaultdict
 
 
 from django import forms
 from django import forms
+from django.apps import apps
 from django.contrib.auth import password_validation
 from django.contrib.auth import password_validation
 from django.contrib.postgres.forms import SimpleArrayField
 from django.contrib.postgres.forms import SimpleArrayField
 from django.core.exceptions import FieldError
 from django.core.exceptions import FieldError
@@ -22,6 +24,7 @@ from utilities.forms.fields import (
 from utilities.forms.rendering import FieldSet
 from utilities.forms.rendering import FieldSet
 from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
 from utilities.forms.widgets import DateTimePicker, SplitMultiSelectWidget
 from utilities.permissions import qs_filter_from_constraints
 from utilities.permissions import qs_filter_from_constraints
+from utilities.string import title
 
 
 __all__ = (
 __all__ = (
     'GroupForm',
     'GroupForm',
@@ -295,10 +298,24 @@ class GroupForm(forms.ModelForm):
 
 
 
 
 def get_object_types_choices():
 def get_object_types_choices():
-    return [
-        (ot.pk, str(ot))
-        for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model')
-    ]
+    """
+    Generate choices for object types grouped by app label using optgroups.
+    Returns nested structure: [(app_label, [(id, model_name), ...]), ...]
+    """
+    app_label_map = {
+        app_config.label: app_config.verbose_name
+        for app_config in apps.get_app_configs()
+    }
+    choices_by_app = defaultdict(list)
+
+    for ot in ObjectType.objects.filter(OBJECTPERMISSION_OBJECT_TYPES).order_by('app_label', 'model'):
+        app_label = app_label_map.get(ot.app_label, ot.app_label)
+
+        model_class = ot.model_class()
+        model_name = model_class._meta.verbose_name if model_class else ot.model
+        choices_by_app[app_label].append((ot.pk, title(model_name)))
+
+    return list(choices_by_app.items())
 
 
 
 
 class ObjectPermissionForm(forms.ModelForm):
 class ObjectPermissionForm(forms.ModelForm):

+ 5 - 5
netbox/users/graphql/filters.py

@@ -5,7 +5,7 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from strawberry_django import DatetimeFilterLookup, FilterLookup
 from strawberry_django import DatetimeFilterLookup, FilterLookup
 
 
-from core.graphql.filter_mixins import BaseObjectTypeFilterMixin
+from netbox.graphql.filters import BaseModelFilter
 from users import models
 from users import models
 
 
 __all__ = (
 __all__ = (
@@ -17,13 +17,13 @@ __all__ = (
 
 
 
 
 @strawberry_django.filter_type(models.Group, lookups=True)
 @strawberry_django.filter_type(models.Group, lookups=True)
-class GroupFilter(BaseObjectTypeFilterMixin):
+class GroupFilter(BaseModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
 @strawberry_django.filter_type(models.User, lookups=True)
 @strawberry_django.filter_type(models.User, lookups=True)
-class UserFilter(BaseObjectTypeFilterMixin):
+class UserFilter(BaseModelFilter):
     username: FilterLookup[str] | None = strawberry_django.filter_field()
     username: FilterLookup[str] | None = strawberry_django.filter_field()
     first_name: FilterLookup[str] | None = strawberry_django.filter_field()
     first_name: FilterLookup[str] | None = strawberry_django.filter_field()
     last_name: FilterLookup[str] | None = strawberry_django.filter_field()
     last_name: FilterLookup[str] | None = strawberry_django.filter_field()
@@ -36,7 +36,7 @@ class UserFilter(BaseObjectTypeFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.Owner, lookups=True)
 @strawberry_django.filter_type(models.Owner, lookups=True)
-class OwnerFilter(BaseObjectTypeFilterMixin):
+class OwnerFilter(BaseModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = (
     group: Annotated['OwnerGroupFilter', strawberry.lazy('users.graphql.filters')] | None = (
@@ -49,6 +49,6 @@ class OwnerFilter(BaseObjectTypeFilterMixin):
 
 
 
 
 @strawberry_django.filter_type(models.OwnerGroup, lookups=True)
 @strawberry_django.filter_type(models.OwnerGroup, lookups=True)
-class OwnerGroupFilter(BaseObjectTypeFilterMixin):
+class OwnerGroupFilter(BaseModelFilter):
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     name: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()
     description: FilterLookup[str] | None = strawberry_django.filter_field()

+ 36 - 13
netbox/utilities/forms/widgets/select.py

@@ -66,18 +66,46 @@ class SelectWithPK(forms.Select):
     option_template_name = 'widgets/select_option_with_pk.html'
     option_template_name = 'widgets/select_option_with_pk.html'
 
 
 
 
-class AvailableOptions(forms.SelectMultiple):
+class SelectMultipleBase(forms.SelectMultiple):
     """
     """
-    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
-    will be empty.) Employed by SplitMultiSelectWidget.
+    Base class for select widgets that filter choices based on selected values.
+    Subclasses should set `include_selected` to control filtering behavior.
     """
     """
+    include_selected = False
+
     def optgroups(self, name, value, attrs=None):
     def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) not in value
-        ]
+        filtered_choices = []
+        include_selected = self.include_selected
+
+        for choice in self.choices:
+            if isinstance(choice[1], (list, tuple)):  # optgroup
+                group_label, group_choices = choice
+                filtered_group = [
+                    c for c in group_choices if (str(c[0]) in value) == include_selected
+                ]
+
+                if filtered_group:  # Only include optgroup if it has choices left
+                    filtered_choices.append((group_label, filtered_group))
+            else:  # option, e.g. flat choice
+                if (str(choice[0]) in value) == include_selected:
+                    filtered_choices.append(choice)
+
+        self.choices = filtered_choices
         value = []  # Clear selected choices
         value = []  # Clear selected choices
         return super().optgroups(name, value, attrs)
         return super().optgroups(name, value, attrs)
 
 
+    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
+        option = super().create_option(name, value, label, selected, index, subindex, attrs)
+        option['attrs']['title'] = label  # Add title attribute to show full text on hover
+        return option
+
+
+class AvailableOptions(SelectMultipleBase):
+    """
+    Renders a <select multiple=true> including only choices that have been selected. (For unbound fields, this list
+    will be empty.) Employed by SplitMultiSelectWidget.
+    """
+
     def get_context(self, name, value, attrs):
     def get_context(self, name, value, attrs):
         context = super().get_context(name, value, attrs)
         context = super().get_context(name, value, attrs)
 
 
@@ -87,17 +115,12 @@ class AvailableOptions(forms.SelectMultiple):
         return context
         return context
 
 
 
 
-class SelectedOptions(forms.SelectMultiple):
+class SelectedOptions(SelectMultipleBase):
     """
     """
     Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
     Renders a <select multiple=true> including only choices that have _not_ been selected. (For unbound fields, this
     will include _all_ choices.) Employed by SplitMultiSelectWidget.
     will include _all_ choices.) Employed by SplitMultiSelectWidget.
     """
     """
-    def optgroups(self, name, value, attrs=None):
-        self.choices = [
-            choice for choice in self.choices if str(choice[0]) in value
-        ]
-        value = []  # Clear selected choices
-        return super().optgroups(name, value, attrs)
+    include_selected = True
 
 
 
 
 class SplitMultiSelectWidget(forms.MultiWidget):
 class SplitMultiSelectWidget(forms.MultiWidget):

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff