Procházet zdrojové kódy

Merge branch 'main' into register-serializer

Arthur před 1 dnem
rodič
revize
544910bac4
100 změnil soubory, kde provedl 6542 přidání a 481 odebrání
  1. 217 0
      .claude/skills/add-config-param/SKILL.md
  2. 168 0
      .claude/skills/remove-config-param/SKILL.md
  3. 217 0
      .claude/skills/remove-model-field/SKILL.md
  4. 194 0
      .claude/skills/remove-model/SKILL.md
  5. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  6. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  7. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  8. 7 9
      .github/PULL_REQUEST_TEMPLATE.md
  9. 149 76
      .github/workflows/ci.yml
  10. 20 0
      AGENTS.md
  11. 0 3
      CONTRIBUTING.md
  12. 356 5
      contrib/openapi.json
  13. 4 0
      docs/best-practices/performance-handbook.md
  14. 10 0
      docs/configuration/graphql-api.md
  15. 1 1
      docs/configuration/required-parameters.md
  16. 21 1
      docs/configuration/system.md
  17. 32 0
      docs/customization/custom-scripts.md
  18. 3 0
      docs/installation/1-postgresql.md
  19. 31 2
      docs/installation/index.md
  20. 18 2
      docs/installation/upgrading.md
  21. 4 1
      docs/integrations/rest-api.md
  22. 3 1
      docs/introduction.md
  23. binární
      docs/media/installation/netbox_application_stack.png
  24. binární
      docs/media/installation/upgrade_paths.png
  25. 7 0
      docs/models/ipam/iprange.md
  26. 2 2
      docs/models/users/token.md
  27. 32 9
      docs/plugins/installation.md
  28. 8 7
      docs/plugins/removal.md
  29. 44 0
      docs/release-notes/version-4.6.md
  30. 16 6
      netbox/circuits/forms/model_forms.py
  31. 2 2
      netbox/circuits/graphql/types.py
  32. 8 0
      netbox/circuits/models/circuits.py
  33. 3 3
      netbox/circuits/tables/circuits.py
  34. 12 12
      netbox/circuits/tests/test_api.py
  35. 43 0
      netbox/circuits/tests/test_forms.py
  36. 20 0
      netbox/circuits/tests/test_models.py
  37. 92 0
      netbox/circuits/tests/test_signals.py
  38. 11 11
      netbox/circuits/tests/test_tables.py
  39. 18 4
      netbox/circuits/tests/test_views.py
  40. 31 2
      netbox/circuits/ui/panels.py
  41. 1 1
      netbox/core/apps.py
  42. 29 1
      netbox/core/checks.py
  43. 17 0
      netbox/core/filtersets.py
  44. 4 1
      netbox/core/jobs.py
  45. 1 1
      netbox/core/management/commands/nbshell.py
  46. 4 4
      netbox/core/tests/test_api.py
  47. 3 3
      netbox/core/tests/test_changelog.py
  48. 2 2
      netbox/core/tests/test_data_backends.py
  49. 50 0
      netbox/core/tests/test_filtersets.py
  50. 387 0
      netbox/core/tests/test_jobs.py
  51. 79 0
      netbox/core/tests/test_management_command_coverage.py
  52. 317 0
      netbox/core/tests/test_management_commands.py
  53. 2 2
      netbox/core/tests/test_models.py
  54. 505 0
      netbox/core/tests/test_signals.py
  55. 5 5
      netbox/core/tests/test_tables.py
  56. 5 0
      netbox/dcim/api/serializers_/rackunits.py
  57. 65 0
      netbox/dcim/filtersets.py
  58. 2 1
      netbox/dcim/forms/model_forms.py
  59. 55 18
      netbox/dcim/graphql/types.py
  60. 15 0
      netbox/dcim/migrations/0234_cablepath_nodes_index.py
  61. 32 0
      netbox/dcim/models/cables.py
  62. 60 1
      netbox/dcim/models/device_components.py
  63. 6 0
      netbox/dcim/signals.py
  64. 78 50
      netbox/dcim/tests/test_api.py
  65. 2 2
      netbox/dcim/tests/test_cable_profiles.py
  66. 13 1
      netbox/dcim/tests/test_cablepaths.py
  67. 1 1
      netbox/dcim/tests/test_cablepaths2.py
  68. 91 0
      netbox/dcim/tests/test_filtersets.py
  69. 150 0
      netbox/dcim/tests/test_management_commands.py
  70. 208 0
      netbox/dcim/tests/test_models.py
  71. 488 0
      netbox/dcim/tests/test_signals.py
  72. 44 36
      netbox/dcim/tests/test_tables.py
  73. 139 57
      netbox/dcim/tests/test_views.py
  74. 2 2
      netbox/extras/api/serializers_/tableconfigs.py
  75. 43 5
      netbox/extras/constants.py
  76. 32 2
      netbox/extras/graphql/filters.py
  77. 16 5
      netbox/extras/graphql/mixins.py
  78. 3 2
      netbox/extras/graphql/types.py
  79. 1 1
      netbox/extras/management/commands/runscript.py
  80. 77 6
      netbox/extras/models/mixins.py
  81. 1 1
      netbox/extras/models/models.py
  82. 28 22
      netbox/extras/tests/test_api.py
  83. 1 1
      netbox/extras/tests/test_conditions.py
  84. 4 4
      netbox/extras/tests/test_custom_validation.py
  85. 6 6
      netbox/extras/tests/test_customfields.py
  86. 3 3
      netbox/extras/tests/test_customvalidators.py
  87. 1 1
      netbox/extras/tests/test_dashboard.py
  88. 1 1
      netbox/extras/tests/test_event_rules.py
  89. 3 3
      netbox/extras/tests/test_forms.py
  90. 329 0
      netbox/extras/tests/test_jobs.py
  91. 504 0
      netbox/extras/tests/test_management_commands.py
  92. 410 13
      netbox/extras/tests/test_models.py
  93. 1 1
      netbox/extras/tests/test_scripts.py
  94. 297 0
      netbox/extras/tests/test_signals.py
  95. 18 18
      netbox/extras/tests/test_tables.py
  96. 1 1
      netbox/extras/tests/test_tags.py
  97. 2 2
      netbox/extras/tests/test_utils.py
  98. 79 26
      netbox/extras/tests/test_views.py
  99. 6 0
      netbox/ipam/forms/filtersets.py
  100. 6 6
      netbox/ipam/graphql/types.py

+ 217 - 0
.claude/skills/add-config-param/SKILL.md

@@ -0,0 +1,217 @@
+---
+name: add-config-param
+description: Step-by-step guide for adding a new configuration parameter to NetBox, covering both static parameters (settings.py) and dynamic parameters (database-backed, editable via the admin UI). Use when the user asks to add a new configuration option, setting, or parameter to NetBox.
+---
+
+# Adding a Configuration Parameter to NetBox
+
+NetBox has two distinct kinds of configuration parameters. Choose the right one before writing any code:
+
+| Type | Where defined | Changed by | Takes effect |
+|---|---|---|---|
+| **Static** | `settings.py` via `getattr(configuration, ...)` | Editing `configuration.py` + restart | On WSGI restart |
+| **Dynamic** | `config/parameters.py` `PARAMS` tuple | Admin UI or `configuration.py` | Immediately (cached in Redis) |
+
+**Use dynamic** when:
+- Operators need to tune the value without a service restart
+- The parameter controls UI behavior or defaults (banners, page sizes, default values)
+- Examples: `PAGINATE_COUNT`, `MAINTENANCE_MODE`, `BANNER_TOP`
+
+**Use static** when:
+- The value must not change at runtime (auth backends, database config, secret keys)
+- The value controls infrastructure that requires a restart anyway
+- Examples: `ALLOWED_HOSTS`, `REMOTE_AUTH_BACKEND`, `LOGGING`
+
+---
+
+## Adding a Dynamic Configuration Parameter
+
+Dynamic parameters are defined in `netbox/netbox/config/parameters.py`, stored in the `ConfigRevision.data` JSONField, cached in Redis, and editable via Admin > System > Configuration History.
+
+### Step 1 — Add to `PARAMS`
+
+**File:** `netbox/netbox/config/parameters.py`
+
+Add a `ConfigParam` entry to the `PARAMS` tuple, grouped logically with related parameters:
+
+```python
+ConfigParam(
+    name='MY_PARAM',
+    label=_('My param'),
+    default=<default_value>,
+    description=_("One-sentence description of what this controls"),
+    field=forms.BooleanField,   # or IntegerField, CharField, JSONField, SimpleArrayField
+    # field_kwargs only when extra widget/validation config is needed:
+    field_kwargs={
+        'widget': forms.Textarea(attrs={'class': 'vLargeTextField'}),
+    },
+),
+```
+
+**Common `field` choices:**
+
+| Field | Use for |
+|---|---|
+| `forms.CharField` (default) | Short strings |
+| `forms.BooleanField` | On/off toggles |
+| `forms.IntegerField` | Counts, sizes, timeouts |
+| `forms.JSONField` | Dicts/lists with free-form structure |
+| `SimpleArrayField` | Lists of strings (add `field_kwargs={'base_field': forms.CharField()}`) |
+
+The `default` value is returned whenever no `ConfigRevision` row exists and the parameter is not hard-coded in `configuration.py`.
+
+### Step 2 — Use the parameter in code
+
+Access via `get_config()` (request-scoped, cached) or the `ConfigItem` callable (deferred):
+
+```python
+from netbox.config import get_config
+
+# One-time read:
+value = get_config().MY_PARAM
+
+# Deferred (evaluated later):
+from netbox.config import ConfigItem
+MY_PARAM = ConfigItem('MY_PARAM')
+```
+
+`get_config()` returns the `Config` object which tries:
+1. Hard-coded value in Django `settings` (set by `configuration.py`)
+2. Redis-cached active `ConfigRevision`
+3. `ConfigParam.default`
+
+### Step 3 — Document in the configuration docs
+
+Add a section to the appropriate file under `docs/configuration/`:
+
+| File | Category |
+|---|---|
+| `miscellaneous.md` | General / doesn't fit elsewhere |
+| `default-values.md` | Default values for object fields |
+| `security.md` | Auth, permissions, URL validation |
+| `data-validation.md` | `CUSTOM_VALIDATORS`, `PROTECTION_RULES` |
+| `graphql-api.md` | GraphQL settings |
+| `error-reporting.md` | Sentry, logging |
+| `remote-authentication.md` | Remote auth settings |
+| `development.md` | Developer-only flags |
+| `system.md` | Low-level system settings |
+
+Template for a dynamic parameter doc section:
+
+```markdown
+## MY_PARAM
+
+!!! tip "Dynamic Configuration Parameter"
+
+Default: `<default_value>`
+
+One or two sentences describing what the parameter does, what values are accepted,
+and any side effects.
+```
+
+### Step 4 — Register in the dynamic params index
+
+**File:** `docs/configuration/index.md`
+
+Add the new parameter to the bulleted list under "Dynamic Configuration Parameters", keeping the list alphabetically ordered:
+
+```markdown
+* [`MY_PARAM`](./miscellaneous.md#my_param)
+```
+
+### Step 5 — Optionally add to the example config
+
+If the parameter is important enough that operators should know they can hard-code it, add a commented entry to `netbox/netbox/configuration_example.py`:
+
+```python
+# MY_PARAM = <default_value>
+```
+
+Place it near related parameters.
+
+### No migration needed
+
+Dynamic parameters are stored in the `ConfigRevision.data` JSONField, which already exists. No database migration is required when adding a new `ConfigParam`.
+
+---
+
+## Adding a Static Configuration Parameter
+
+Static parameters live in `settings.py` and are read at startup from `configuration.py`. They take effect only after the WSGI service is restarted.
+
+### Step 1 — Add to `settings.py`
+
+**File:** `netbox/netbox/settings.py`
+
+Add a line in the "Set static config parameters" block, alphabetically within its logical group:
+
+```python
+MY_PARAM = getattr(configuration, 'MY_PARAM', <default_value>)
+```
+
+For required parameters (no default), use `getattr(configuration, 'MY_PARAM')` with no fallback and add the parameter name to the required check near the top:
+
+```python
+for parameter in ('ALLOWED_HOSTS', 'MY_PARAM', 'SECRET_KEY', 'REDIS'):
+    if not hasattr(configuration, parameter):
+        raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.")
+```
+
+### Step 2 — Add validation (if needed)
+
+If the parameter has constrained values, add an `ImproperlyConfigured` check immediately after the `getattr` line:
+
+```python
+MY_PARAM = getattr(configuration, 'MY_PARAM', 'option_a')
+if MY_PARAM not in ('option_a', 'option_b'):
+    raise ImproperlyConfigured(f"MY_PARAM must be 'option_a' or 'option_b' (found {MY_PARAM})")
+```
+
+For complex validation (importable paths, valid URLs, etc.) follow the patterns of `PROXY_ROUTERS` or `RELEASE_CHECK_URL` in `settings.py`.
+
+### Step 3 — Add to the example config
+
+**File:** `netbox/netbox/configuration_example.py`
+
+Add a commented entry with a brief inline comment explaining the parameter:
+
+```python
+# MY_PARAM = 'default_value'    # Short description of what this does
+```
+
+### Step 4 — Document
+
+Add a section to the appropriate `docs/configuration/*.md` file:
+
+```markdown
+## MY_PARAM
+
+Default: `<default_value>`
+
+One or two sentences describing the parameter, accepted values, and any constraints.
+
+---
+```
+
+Static parameters do **not** get the `!!! tip "Dynamic Configuration Parameter"` admonition.
+
+---
+
+## Common Gotchas
+
+- **Dynamic params don't need a migration** — the value is stored in the `ConfigRevision.data` JSONField which already exists.
+- **Hard-coding a dynamic param in `configuration.py` overrides the UI** — the loop at the bottom of `settings.py` (`for param in CONFIG_PARAMS: ...`) sets the Django setting, which `Config.__getattr__` checks first. Document this behaviour in the parameter's doc page.
+- **`forms.BooleanField` with `required=False`**: the `ConfigFormMetaclass` always adds `required=False`, so a `BooleanField` correctly represents a three-state (True / False / unset-use-default) UI. No extra `field_kwargs` needed for booleans.
+- **`SimpleArrayField` needs `base_field`**: always pass `field_kwargs={'base_field': forms.CharField()}`.
+- **No `ruff format`** on existing files — use `ruff check` only.
+
+## References
+
+- Dynamic param definitions: `netbox/netbox/config/parameters.py`
+- Config loading / `Config` class: `netbox/netbox/config/__init__.py`
+- `ConfigRevision` model: `netbox/core/models/config.py`
+- `ConfigRevisionForm` (metaclass): `netbox/core/forms/model_forms.py`
+- Static config loading: `netbox/netbox/settings.py` lines 67–213
+- Example config: `netbox/netbox/configuration_example.py`
+- Config tests: `netbox/netbox/tests/test_config.py`
+- Documentation: `docs/configuration/`

+ 168 - 0
.claude/skills/remove-config-param/SKILL.md

@@ -0,0 +1,168 @@
+---
+name: remove-config-param
+description: Step-by-step guide for removing a configuration parameter from NetBox, covering both static parameters (settings.py) and dynamic parameters (database-backed). Use when the user asks to remove, delete, or deprecate a configuration option or setting.
+---
+
+# Removing a Configuration Parameter from NetBox
+
+Before touching any files, determine which type of parameter you are removing:
+
+| Type | Where defined | How to tell |
+|---|---|---|
+| **Static** | `settings.py` via `getattr(configuration, ...)` | Appears in `settings.py`; not in `config/parameters.py` `PARAMS` |
+| **Dynamic** | `config/parameters.py` `PARAMS` tuple | Appears in `PARAMS`; editable via Admin > System > Configuration History |
+
+Run a broad grep before starting to find all usages:
+
+```bash
+grep -r 'MY_PARAM' netbox/ --include='*.py' -l
+grep -r 'MY_PARAM' docs/ -l
+```
+
+---
+
+## Removing a Dynamic Configuration Parameter
+
+### Step 1 — Find all usages in code
+
+Before removing the parameter definition, identify every call site:
+
+```bash
+grep -r 'MY_PARAM\|my_param' netbox/ --include='*.py'
+```
+
+For `get_config().MY_PARAM` and `ConfigItem('MY_PARAM')` patterns specifically:
+
+```bash
+grep -r "get_config()\.MY_PARAM\|ConfigItem('MY_PARAM')" netbox/ --include='*.py'
+```
+
+Remove or replace every usage. The replacement depends on the reason for removal:
+- **Parameter folded into another**: replace with the new parameter access
+- **Hard-coded default**: replace `get_config().MY_PARAM` with the literal default value
+- **Feature removed**: remove the surrounding code entirely
+
+### Step 2 — Remove from `PARAMS`
+
+**File:** `netbox/netbox/config/parameters.py`
+
+Delete the `ConfigParam(...)` block for the parameter from the `PARAMS` tuple.
+
+### Step 3 — Remove from the dynamic params index
+
+**File:** `docs/configuration/index.md`
+
+Remove the bullet-point entry for `MY_PARAM` from the "Dynamic Configuration Parameters" list.
+
+### Step 4 — Remove the documentation section
+
+**File:** `docs/configuration/<category>.md` (whichever file the parameter was documented in)
+
+Delete the `## MY_PARAM` section and its content, including the trailing `---` separator.
+
+### Step 5 — Remove from the example config (if present)
+
+**File:** `netbox/netbox/configuration_example.py`
+
+If a commented `# MY_PARAM = ...` line was added when the parameter was introduced, remove it.
+
+### No migration needed
+
+Dynamic parameters are stored as keys in the `ConfigRevision.data` JSONField. Removing the `ConfigParam` definition from `PARAMS` means the UI no longer shows the field and the `Config` object no longer exposes the attribute — but old `ConfigRevision` rows in the database will silently retain the key in their JSON blob. This is harmless and requires no migration.
+
+---
+
+## Removing a Static Configuration Parameter
+
+### Step 1 — Find all usages in code
+
+```bash
+grep -r 'MY_PARAM' netbox/ --include='*.py'
+```
+
+Remove every reference. For Django settings accessed via `settings.MY_PARAM`, also search templates:
+
+```bash
+grep -r 'MY_PARAM' netbox/templates/
+```
+
+### Step 2 — Remove from `settings.py`
+
+**File:** `netbox/netbox/settings.py`
+
+1. Delete the `MY_PARAM = getattr(configuration, 'MY_PARAM', ...)` line.
+2. If the parameter was required (listed in the required-parameter check near the top), remove it from that tuple:
+   ```python
+   # Before:
+   for parameter in ('ALLOWED_HOSTS', 'MY_PARAM', 'SECRET_KEY', 'REDIS'):
+   # After:
+   for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
+   ```
+3. Remove any validation block that immediately followed the `getattr` line (e.g. `if MY_PARAM not in (...): raise ImproperlyConfigured(...)`).
+
+### Step 3 — Remove from the example config
+
+**File:** `netbox/netbox/configuration_example.py`
+
+Delete the commented `# MY_PARAM = ...` line.
+
+### Step 4 — Remove the documentation section
+
+**File:** `docs/configuration/<category>.md`
+
+Delete the `## MY_PARAM` section and its content, including the trailing `---` separator.
+
+---
+
+## Deprecation vs. Immediate Removal
+
+If the parameter is used by existing deployments, consider a two-phase removal:
+
+**Phase 1 (current release) — Deprecate:**
+1. Keep the `getattr` / `ConfigParam` definition in place so existing configs don't break.
+2. Add a deprecation warning comment in `settings.py` (see how `SENTRY_DSN` is handled with `# TODO: Remove in NetBox vX.Y`).
+3. Log a `warnings.warn(...)` or add a startup notice if the parameter is still set.
+4. Mark the doc section as deprecated.
+
+**Phase 2 (future release) — Remove:**
+Follow the full removal steps above.
+
+---
+
+## Common Gotchas
+
+- **Remove all call sites first** — if code still calls `get_config().MY_PARAM` or `settings.MY_PARAM` after the definition is gone, startup or runtime will raise `AttributeError`.
+- **Old `ConfigRevision` rows retain the key in their JSON blob** — this is harmless and requires no migration. The risk is code: any remaining call to `get_config().MY_PARAM` or `settings.MY_PARAM` after the definition is gone will raise `AttributeError`. Remove all code references *before* removing the `ConfigParam` definition.
+- **`configuration.py` in user deployments** — removing a static parameter may cause a `TypeError` or silent failure if users have `MY_PARAM = ...` in their local `configuration.py`. Document the removal in the release notes.
+- **No `ruff format`** on existing files — use `ruff check` only.
+
+## Summary Checklist
+
+### Dynamic parameter
+
+| # | File(s) | Action |
+|---|---|---|
+| 1 | All `.py` files | Remove all `get_config().MY_PARAM` and `ConfigItem('MY_PARAM')` usages |
+| 2 | `netbox/netbox/config/parameters.py` | Remove `ConfigParam(...)` block from `PARAMS` |
+| 3 | `docs/configuration/index.md` | Remove bullet-point entry |
+| 4 | `docs/configuration/<category>.md` | Remove `## MY_PARAM` section |
+| 5 | `netbox/netbox/configuration_example.py` | Remove commented entry (if present) |
+
+### Static parameter
+
+| # | File(s) | Action |
+|---|---|---|
+| 1 | All `.py` and template files | Remove all `settings.MY_PARAM` / `MY_PARAM` usages |
+| 2 | `netbox/netbox/settings.py` | Remove `getattr` line; remove from required-params tuple; remove validation block |
+| 3 | `netbox/netbox/configuration_example.py` | Remove commented entry |
+| 4 | `docs/configuration/<category>.md` | Remove `## MY_PARAM` section |
+
+## References
+
+- Dynamic param definitions: `netbox/netbox/config/parameters.py`
+- Config loading / `Config` class: `netbox/netbox/config/__init__.py`
+- `ConfigRevision` model: `netbox/core/models/config.py`
+- Static config loading: `netbox/netbox/settings.py` lines 67–213
+- Example config: `netbox/netbox/configuration_example.py`
+- Documentation: `docs/configuration/`
+- `add-config-param` skill: `.claude/skills/add-config-param/SKILL.md` (reverse of this skill)

+ 217 - 0
.claude/skills/remove-model-field/SKILL.md

@@ -0,0 +1,217 @@
+---
+name: remove-model-field
+description: Step-by-step checklist for removing a field from an existing NetBox model, covering all required touch points (model, migration, serializer, forms, filterset, table, panel/template, search, GraphQL, tests, docs). Use when the user asks to remove or delete a field or attribute from an existing model.
+---
+
+# Removing a Field from an Existing NetBox Model
+
+Removing a field touches many files. Work through the checklist below in order — remove outer consumers first (tests, docs, GraphQL, API, forms) before touching the model definition itself.
+
+## Before You Start
+
+Determine upfront:
+- **Field name** and which **model/app** owns it
+- **Field type**: scalar, FK/M2M, GenericForeignKey, or special (JSONField, etc.)
+- **All references** — run a broad grep before touching anything:
+
+```bash
+grep -r 'new_field\|related_thing' netbox/ --include='*.py' -l
+grep -r 'new_field\|related_thing' docs/ -l
+```
+
+For FK/M2M fields, also check for FilterSet `_id` companions and GraphQL lazy annotations referencing this field.
+
+**Check dependents**: if other models or code use this field (e.g. ordering, constraints, signal handlers), those references must be cleaned up too.
+
+## 1. Update Tests
+
+Update test files to remove references to the field being deleted. Specifically:
+
+- **`tests/test_filtersets.py`** — remove `test_<field>` and `test_<field>_id` methods; remove the field from `setUpTestData` test objects.
+- **`tests/test_api.py`** — remove the field from `setUpTestData`, `create_data`, and `bulk_update_data`; remove any `test_list_objects_by_<field>` methods.
+- **`tests/test_views.py`** — remove the field from `form_data`, `bulk_edit_data`, and `csv_data` in `setUpTestData`.
+- **`tests/test_models.py`** — remove any `test_clean_<field>` or constraint tests specific to this field.
+
+## 2. Update Documentation
+
+**File:** `docs/models/<app>/<modelname>.md`
+
+Remove the field's entry from the `## Fields` section. If the field had any cross-references in other doc pages, remove those too.
+
+## 3. Update GraphQL
+
+### Filter — `graphql/filters.py`
+
+Remove the filter field declaration(s) for the deleted field:
+
+```python
+# Remove lines like:
+new_field: StrFilterLookup[str] | None = strawberry_django.filter_field()
+
+# Or for FK:
+related_thing: Annotated[...] | None = strawberry_django.filter_field()
+related_thing_id: ID | None = strawberry_django.filter_field()
+```
+
+### Type — `graphql/types.py`
+
+For simple fields, `fields='__all__'` means no change is needed — the field disappears automatically once removed from the model.
+
+For FK fields with an explicit annotation, remove the annotation line:
+
+```python
+# Remove:
+related_thing: Annotated['RelatedThingType', strawberry.lazy('<app>.graphql.types')] | None
+```
+
+If the field was in an `exclude` list, remove it from the exclude list (it no longer exists to exclude).
+
+## 4. Update the API Serializer
+
+**File:** `netbox/<app>/api/serializers_/<module>.py`
+
+- **Simple field**: remove the field name from `Meta.fields` (and `brief_fields` if present).
+- **FK field**: remove the serializer field declaration and its name from `Meta.fields`:
+
+```python
+# Remove:
+related_thing = RelatedThingSerializer(nested=True, required=False, allow_null=True)
+# And remove 'related_thing' from Meta.fields
+```
+
+## 5. Update Forms
+
+There are typically up to four forms to update. Find them under `netbox/<app>/forms/`.
+
+### 5a. Filter form — `forms/filtersets.py`
+
+- Remove the field from `fieldsets`.
+- Remove the filter field declaration (e.g. `new_field = forms.CharField(...)` or the `DynamicModelMultipleChoiceField`).
+
+### 5b. Bulk edit form — `forms/bulk_edit.py`
+
+- Remove the field from `fieldsets` and `Meta.fields` (if present).
+- Remove the field declaration.
+- Remove from `nullable_fields` if listed there.
+
+### 5c. Bulk import form — `forms/bulk_import.py`
+
+- Remove from `Meta.fields`.
+- Remove any explicit field declaration.
+
+### 5d. Model form — `model_forms.py`
+
+- Remove from `fieldsets`.
+- Remove from `Meta.fields`.
+- Remove any explicit field declaration (e.g. a `DynamicModelChoiceField`).
+
+## 6. Update the FilterSet
+
+**File:** `netbox/<app>/filtersets.py`
+
+- **Simple field**: remove from `Meta.fields`.
+- **FK field**: remove both the `<field>` and `<field>_id` explicit filter declarations.
+- **`search()` method**: if the field was included in the `Q(...)` chain, remove that clause.
+- Remove any now-unused imports (e.g. the related model import if it was only used by this filter).
+
+## 7. Update the Table
+
+**File:** `netbox/<app>/tables/<module>.py`
+
+- Remove the column declaration (e.g. `related_thing = tables.Column(linkify=True)`).
+- Remove the field from `Meta.fields`.
+- Remove from `default_columns` if listed there.
+
+## 8. Update the Detail View Panel
+
+**File:** `netbox/<app>/ui/panels.py`
+
+Find the panel class for the model and remove the attribute declaration:
+
+```python
+# Remove:
+new_field = attrs.TextAttr('new_field')
+related_thing = attrs.RelatedObjectAttr('related_thing', linkify=True)
+```
+
+If the model uses a legacy HTML template (`netbox/templates/<app>/`) rather than a declarative panel, remove the corresponding `<tr>` row from that template instead.
+
+## 9. Update the SearchIndex
+
+**File:** `netbox/<app>/search.py`
+
+If the field was indexed for global search, remove it from the `fields` tuple:
+
+```python
+# Remove:
+('new_field', 300),
+```
+
+## 10. Remove the Field from the Model
+
+**File:** `netbox/<app>/models/<module>.py`
+
+1. Delete the field declaration.
+2. If the field was in `clone_fields`, remove it from that tuple.
+3. If `clean()` had validation logic specific to this field, remove those clauses. If `clean()` becomes empty, remove the override entirely.
+4. For FK fields: remove the `related_name` on the target model is automatic (Django handles it). If the FK was the only reason a related model was imported, remove that import too.
+5. Check `Meta` for references to the field:
+   - `ordering` — if the field appears in the ordering tuple, remove it (or replace with a remaining field if ordering would otherwise become empty).
+   - `constraints` — remove any `UniqueConstraint` or `CheckConstraint` whose `fields` list includes this field; if only this field remains, remove the constraint entirely; if other fields remain, remove just this field from the list.
+   - `indexes` — remove any `models.Index` that includes this field.
+6. For GenericForeignKey fields: if this was the only GFK, also remove the `object_type` ContentType FK and `object_id` integer field, and remove the `models.Index(fields=('object_type', 'object_id'))` from `Meta`.
+
+## 11. Generate the Migration
+
+**Do NOT write migrations manually.** Tell the user to run:
+
+```bash
+cd netbox/
+python manage.py makemigrations <app> -n remove_<field>_from_<model> --no-header
+```
+
+Set `DEVELOPER = True` in `configuration.py` if the command is blocked.
+
+Review the generated migration — it should contain only a `RemoveField` operation (plus any index removal for GFK fields). Apply with:
+
+```bash
+python manage.py migrate
+```
+
+## Summary Checklist
+
+| # | File(s) | Action |
+|---|---|---|
+| 1 | `tests/test_*.py` | Remove field from test data, filter tests, API tests, view tests |
+| 2 | `docs/models/<app>/<model>.md` | Remove field from `## Fields` section |
+| 3 | `graphql/filters.py`, `types.py` | Remove filter field; remove FK annotation if explicit |
+| 4 | `api/serializers_/<module>.py` | Remove from `Meta.fields`; remove FK serializer field |
+| 5a | `forms/filtersets.py` | Remove from `fieldsets`; remove filter field declaration |
+| 5b | `forms/bulk_edit.py` | Remove from `fieldsets`, `Meta.fields`, `nullable_fields` |
+| 5c | `forms/bulk_import.py` | Remove from `Meta.fields` and field declaration |
+| 5d | `forms/model_forms.py` | Remove from `fieldsets`, `Meta.fields`, and field declaration |
+| 6 | `filtersets.py` | Remove from `Meta.fields`; remove FK + FK_id pair; update `search()` |
+| 7 | `tables/<module>.py` | Remove column declaration and from `Meta.fields`, `default_columns` |
+| 8 | `<app>/ui/panels.py` | Remove attr declaration from panel class |
+| 9 | `search.py` | Remove from SearchIndex `fields` tuple |
+| 10 | `models/<module>.py` | Remove field; clean up `clone_fields`, `clean()`, `Meta` ordering/constraints/indexes, imports |
+| 11 | (user runs) | `makemigrations <app> -n remove_<field>_from_<model> --no-header` then `migrate` |
+
+## Common Gotchas
+
+- **Work outside-in** — remove tests, docs, GraphQL, and API references before touching the model, to avoid import errors during the process.
+- **FK fields leave no `_id` companion in serializers** — the modern pattern uses a single `field = Serializer(nested=True)`. Grep for the field name and the serializer class name.
+- **FilterSets have both `<field>` and `<field>_id`** — both must be removed; they are explicit declarations, not auto-generated.
+- **`clone_fields`** must be updated if the field was listed there.
+- **`search()` in filtersets** — if the field was in the `Q(...)` chain of the `search()` method, that clause must be removed to avoid a `FieldError` at runtime.
+- **`brief_fields` in serializers** — remove explicitly if the field was listed.
+- **`makemigrations` must be run**, not written manually. If blocked, set `DEVELOPER = True` in `configuration.py`.
+- **No `ruff format`** on existing files — use `ruff check` only.
+
+## References
+
+- Panel attrs reference: `netbox/netbox/ui/attrs.py`
+- Panel classes: `netbox/<app>/ui/panels.py`
+- Base filterset classes: `netbox/netbox/filtersets.py`
+- `add-model-field` skill: `.claude/skills/add-model-field/SKILL.md` (reverse of this skill)
+- Contributing guide: `docs/development/extending-models.md`

+ 194 - 0
.claude/skills/remove-model/SKILL.md

@@ -0,0 +1,194 @@
+---
+name: remove-model
+description: Step-by-step guide for removing an existing model from NetBox, covering all required touch points in safe deletion order (tests, docs, nav, search, GraphQL, API, views, URLs, forms, filterset, table, choices, model, migration). Use when the user asks to remove, delete, or deprecate a model or object type from NetBox.
+---
+
+# Removing a Model from NetBox
+
+Removing a model requires undoing ~13 components. Work in the order below — remove consumers before providers to avoid import errors during the process. Deleting a model is **irreversible once migrated**; confirm with the user before running `makemigrations`.
+
+## 0. Before You Start
+
+Identify:
+- **Model name** and **app** — e.g. `MyModel` in `dcim`
+- **All references** — run a broad grep before touching anything:
+
+```bash
+grep -r 'MyModel\|mymodel\|my-model\|my_model' netbox/ --include='*.py' -l
+grep -r 'MyModel\|mymodel\|my-model\|my_model' docs/ -l
+grep -r 'mymodel\|my-model' netbox/netbox/navigation/ --include='*.py'
+```
+
+Check for:
+- Other models with ForeignKey / M2M pointing to this model (they need updating or their own removal first)
+- Generic relations via `FeatureQuery` or `ContentType` that reference this model
+- Any plugin or external code documented as depending on this model
+
+**Do not proceed if other retained models have non-nullable FKs to this model** — those FK fields must be removed or made nullable first.
+
+## 1. Remove Tests
+
+Delete test methods or entire test classes that exist solely for this model. If the test file contains only this model's tests, delete the file; otherwise remove just the relevant class(es).
+
+Files to check:
+- `netbox/<app>/tests/test_api.py`
+- `netbox/<app>/tests/test_views.py`
+- `netbox/<app>/tests/test_filtersets.py`
+- `netbox/<app>/tests/test_models.py`
+- `netbox/<app>/tests/test_forms.py`
+- `netbox/<app>/tests/test_tables.py`
+- Any app-specific test modules (e.g. `test_cablepaths.py`)
+
+## 2. Remove Documentation
+
+1. Delete `docs/models/<app>/<modelname>.md`.
+2. Remove the `mkdocs.yml` entry under the relevant `nav:` group.
+3. Remove the entry from `docs/development/models.md` (the "Models Index" list).
+
+## 3. Remove Navigation Menu Entry
+
+**File:** `netbox/netbox/navigation/menu.py`
+
+Remove the `get_model_item('<app>', 'mymodel', ...)` line from the relevant `MenuGroup`.
+
+## 4. Remove from Search Index
+
+**File:** `netbox/<app>/search.py`
+
+Delete the `@register_search` class for the model. If the file becomes empty (no other indexes), delete the file itself.
+
+## 5. Remove GraphQL
+
+Remove in this order (schema depends on types, types depend on filters):
+
+1. **`netbox/<app>/graphql/schema.py`** — remove the `my_model` and `my_model_list` fields from the app's `Query` type.
+2. **`netbox/<app>/graphql/types.py`** — remove the `MyModelType` class and its `__all__` entry.
+3. **`netbox/<app>/graphql/filters.py`** — remove the `MyModelFilter` class and its `__all__` entry.
+
+If any remaining type in `types.py` has a lazy annotation referencing `MyModelType`, remove that annotation too.
+
+## 6. Remove REST API
+
+1. **`netbox/<app>/api/urls.py`** — remove the `router.register('my-models', ...)` line.
+2. **`netbox/<app>/api/views.py`** — remove the `MyModelViewSet` class.
+3. **`netbox/<app>/api/serializers_/<module>.py`** — remove the serializer class. If this was the only serializer in the module, delete the file and remove its `from .<module> import *` line from `serializers_/__init__.py`.
+
+Also check other serializers that reference this model (e.g. `MyModelSerializer(nested=True)` on related serializers) and remove those fields too.
+
+## 7. Remove URL Routes
+
+**File:** `netbox/<app>/urls.py`
+
+Remove the two `path(...)` entries that call `get_model_urls('<app>', 'mymodel', ...)`.
+
+## 8. Remove Views
+
+**File:** `netbox/<app>/views.py`
+
+Remove all view classes decorated with `@register_model_view(MyModel, ...)`. There are typically seven:
+
+- `MyModelListView`
+- `MyModelView`
+- `MyModelEditView`
+- `MyModelDeleteView`
+- `MyModelBulkImportView`
+- `MyModelBulkEditView`
+- `MyModelBulkDeleteView`
+- `MyModelBulkRenameView` (if present)
+
+Also remove the panel class from `netbox/<app>/ui/panels.py` and any `layout` references using it.
+
+If there is a model-specific HTML template (`netbox/templates/<app>/mymodel.html` or similar), delete it.
+
+## 9. Remove Table
+
+**File:** `netbox/<app>/tables/<module>.py`
+
+Remove the `MyModelTable` class. If it is the sole table in the module, delete the file and clean up the `__init__.py` re-export.
+
+**File:** `netbox/<app>/tables/__init__.py`
+
+Remove the corresponding `from .<module> import *` or named import.
+
+## 10. Remove Forms
+
+Remove in dependency order (bulk forms depend on the model form):
+
+1. **`netbox/<app>/forms/bulk_import.py`** — remove `MyModelImportForm`.
+2. **`netbox/<app>/forms/bulk_edit.py`** — remove `MyModelBulkEditForm`.
+3. **`netbox/<app>/forms/filtersets.py`** — remove `MyModelFilterForm`.
+4. **`netbox/<app>/forms/model_forms.py`** — remove `MyModelForm`.
+5. **`netbox/<app>/forms/__init__.py`** — remove all re-exports of the deleted form classes.
+
+## 11. Remove FilterSet
+
+**File:** `netbox/<app>/filtersets.py`
+
+Remove the `MyModelFilterSet` class. Also remove any imports of `MyModel` or related models that were only used by this filterset.
+
+## 12. Remove Choices
+
+**File:** `netbox/<app>/choices.py`
+
+Remove any `ChoiceSet` subclasses that were defined exclusively for this model (e.g. `MyModelStatusChoices`). Leave choices that are shared with other models.
+
+## 13. Remove the Model
+
+**File:** `netbox/<app>/models/<module>.py` (or `models.py`)
+
+1. Delete the `MyModel` class.
+2. Remove `'MyModel'` from `__all__` in the module.
+3. Remove the import line in `netbox/<app>/models/__init__.py` if this was the last model in the submodule (or remove just the `MyModel` name from a `from .<module> import ...` line).
+4. Remove any now-unused imports in the model file itself.
+
+## 14. Generate the Migration
+
+**Do NOT write migrations manually.** Tell the user to run:
+
+```bash
+cd netbox/
+python manage.py makemigrations <app> -n remove_mymodel --no-header
+```
+
+Set `DEVELOPER = True` in `configuration.py` if the command is blocked.
+
+Review the generated migration before applying — it should only contain a `DeleteModel` operation (plus any `RemoveField` operations for FKs on other models if Django detected them). Apply with:
+
+```bash
+python manage.py migrate
+```
+
+## Common Gotchas
+
+- **Remove consumers before providers** — tests, docs, GraphQL schema, API viewset, URL routes, and views all reference the model; remove them before removing the model itself to avoid import errors.
+- **FK cleanup** — Django will detect FKs pointing at the deleted model and auto-add `RemoveField` operations to the migration. Verify the migration is correct before running it.
+- **ContentType cleanup** — after migrating, `ContentType` rows for the old model linger in the database. They are harmless but can be cleaned up with `python manage.py remove_stale_contenttypes`.
+- **`__all__` entries** — grep all `__init__.py` files for the model name after removing the class; dangling re-exports cause `ImportError` at startup.
+- **Serializer references** — other serializers may have a nested `MyModelSerializer(nested=True)` field. Search for the serializer class name as well as the model name.
+- **`manage.py` lives in `netbox/`**, not the repo root.
+- **No `ruff format`** on existing files — use `ruff check` only.
+
+## Summary Checklist
+
+| # | File(s) | Action |
+|---|---|---|
+| 1 | `tests/test_*.py` | Remove test classes for this model |
+| 2 | `docs/models/<app>/<model>.md`, `mkdocs.yml`, `docs/development/models.md` | Delete doc page; remove nav entries |
+| 3 | `netbox/netbox/navigation/menu.py` | Remove `get_model_item(...)` line |
+| 4 | `<app>/search.py` | Remove `SearchIndex` class |
+| 5 | `<app>/graphql/schema.py`, `types.py`, `filters.py` | Remove query fields, type, filter |
+| 6 | `<app>/api/urls.py`, `views.py`, `serializers_/<module>.py` | Remove router entry, viewset, serializer |
+| 7 | `<app>/urls.py` | Remove `get_model_urls(...)` paths |
+| 8 | `<app>/views.py`, `<app>/ui/panels.py` | Remove all view classes and panel |
+| 9 | `<app>/tables/<module>.py`, `tables/__init__.py` | Remove table class and re-export |
+| 10 | `<app>/forms/*.py`, `forms/__init__.py` | Remove all four form classes and re-exports |
+| 11 | `<app>/filtersets.py` | Remove `FilterSet` class |
+| 12 | `<app>/choices.py` | Remove model-specific `ChoiceSet` subclasses |
+| 13 | `<app>/models/<module>.py`, `models/__init__.py` | Remove model class and `__all__` entry |
+| 14 | (user runs) | `makemigrations <app> -n remove_mymodel --no-header` then `migrate` |
+
+## References
+
+- Model base classes: `netbox/netbox/models/__init__.py`
+- Navigation menu: `netbox/netbox/navigation/menu.py`
+- `add-model` skill: `.claude/skills/add-model/SKILL.md` (reverse of this skill)

+ 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.6.0
+      placeholder: v4.6.1
     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.6.0
+      placeholder: v4.6.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

@@ -8,7 +8,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.6.0
+      placeholder: v4.6.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 7 - 9
.github/PULL_REQUEST_TEMPLATE.md

@@ -1,16 +1,14 @@
 <!--
 <!--
-    Thank you for your interest in contributing to NetBox! Please note that
-    our contribution policy requires that a feature request or bug report be
-    approved and assigned prior to opening a pull request. This helps avoid
-    waste time and effort on a proposed change that we might not be able to
-    accept.
+    Thank you for your interest in contributing to NetBox! Before submitting a
+    PR, please verify the following:
 
 
-    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
-    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
+      1. An issue has been opened to capture these changes
+      2. The issue has been accepted and assigned to you for work
 
 
-    Please specify your assigned issue number on the line below.
+    Pull requests which do not reference an assigned issue will be closed
+    automatically. Please specify your assigned issue number on the line below.
 -->
 -->
-### Fixes: #1234
+### Closes: #1234
 
 
 <!--
 <!--
     Please include a summary of the proposed changes below.
     Please include a summary of the proposed changes below.

+ 149 - 76
.github/workflows/ci.yml

@@ -3,22 +3,24 @@ name: CI
 
 
 on:
 on:
   push:
   push:
+    branches:
+      - main
+      - feature
     paths-ignore:
     paths-ignore:
       - '.github/ISSUE_TEMPLATE/**'
       - '.github/ISSUE_TEMPLATE/**'
       - '.github/PULL_REQUEST_TEMPLATE.md'
       - '.github/PULL_REQUEST_TEMPLATE.md'
       - 'contrib/**'
       - 'contrib/**'
-      - 'docs/**'
       - 'netbox/translations/**'
       - 'netbox/translations/**'
   pull_request:
   pull_request:
     paths-ignore:
     paths-ignore:
       - '.github/ISSUE_TEMPLATE/**'
       - '.github/ISSUE_TEMPLATE/**'
       - '.github/PULL_REQUEST_TEMPLATE.md'
       - '.github/PULL_REQUEST_TEMPLATE.md'
       - 'contrib/**'
       - 'contrib/**'
-      - 'docs/**'
       - 'netbox/translations/**'
       - 'netbox/translations/**'
 
 
 permissions:
 permissions:
   contents: read
   contents: read
+  pull-requests: read
 
 
 # Add concurrency group to control job running
 # Add concurrency group to control job running
 concurrency:
 concurrency:
@@ -26,22 +28,67 @@ concurrency:
   cancel-in-progress: true
   cancel-in-progress: true
 
 
 jobs:
 jobs:
-  build:
+
+  # Detect which areas of the codebase changed so downstream jobs can be skipped
+  # when their inputs haven't changed. Jobs that don't match any filter are shown
+  # as "skipped" in GitHub's check list, which satisfies required-status-checks.
+  changes:
+    name: Detect changed files
+    runs-on: ubuntu-latest
+    outputs:
+      python: ${{ steps.filter.outputs.python }}
+      frontend: ${{ steps.filter.outputs.frontend }}
+      docs: ${{ steps.filter.outputs.docs }}
+    steps:
+      - name: Check out repo
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
+
+      - name: Detect changed files
+        uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d  # v4.0.1
+        id: filter
+        with:
+          filters: |
+            python:
+              - 'netbox/**/*.py'
+              - 'requirements*.txt'
+              - 'pyproject.toml'
+            frontend:
+              - 'netbox/project-static/**'
+            docs:
+              - 'docs/**'
+              - 'mkdocs.yml'
+
+  lint:
+    name: Lint (Python)
+    needs: changes
+    if: needs.changes.result == 'success' && needs.changes.outputs.python == 'true'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out repo
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
+
+      - name: Check Python linting & PEP8 compliance
+        uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b  # v4.0.0
+        with:
+          version: "0.15.10"
+          args: "check --output-format=github"
+          src: "netbox/"
+
+  test:
     name: >-
     name: >-
-      Tests (Python ${{ matrix.python-version }},
-      Node ${{ matrix.node-version }}${{ matrix.coverage && ', coverage' || '' }})
+      Tests (Python ${{ matrix.python-version }}${{ matrix.coverage && ', coverage' || '' }})
+    needs: changes
+    if: needs.changes.result == 'success' && needs.changes.outputs.python == 'true'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     env:
     env:
       NETBOX_CONFIGURATION: netbox.configuration_testing
       NETBOX_CONFIGURATION: netbox.configuration_testing
     strategy:
     strategy:
       matrix:
       matrix:
         python-version: ['3.12', '3.13', '3.14']
         python-version: ['3.12', '3.13', '3.14']
-        node-version: ['20.x']
         include:
         include:
           - coverage: false
           - coverage: false
           # Run coverage only once, using the Python 3.14 job.
           # Run coverage only once, using the Python 3.14 job.
           - python-version: '3.14'
           - python-version: '3.14'
-            node-version: '20.x'
             coverage: true
             coverage: true
     services:
     services:
       redis:
       redis:
@@ -62,72 +109,98 @@ jobs:
           - 5432:5432
           - 5432:5432
 
 
     steps:
     steps:
-    - name: Check out repo
-      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
-
-    - name: Check Python linting & PEP8 compliance
-      uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b  # v4.0.0
-      with:
-        version: "0.15.10"
-        args: "check --output-format=github"
-        src: "netbox/"
-
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
-      with:
-        python-version: ${{ matrix.python-version }}
-
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
-      with:
-        node-version: ${{ matrix.node-version }}
-
-    - name: Install Yarn Package Manager
-      run: npm install -g yarn
-
-    - name: Setup Node.js with Yarn Caching
-      uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
-      with:
-        node-version: ${{ matrix.node-version }}
-        cache: yarn
-        cache-dependency-path: netbox/project-static/yarn.lock
-
-    - name: Install Frontend Dependencies
-      run: yarn --cwd netbox/project-static
-
-    - name: Install dependencies & set up configuration
-      run: |
-        python -m pip install --upgrade pip
-        pip install -r requirements.txt
-        pip install coverage tblib
-
-    - name: Build documentation
-      run: zensical build
-
-    - name: Collect static files
-      run: python netbox/manage.py collectstatic --no-input
-
-    - name: Check for missing migrations
-      run: python netbox/manage.py makemigrations --check
-
-    - name: Check UI ESLint, TypeScript, and Prettier Compliance
-      run: yarn --cwd netbox/project-static validate
-
-    - name: Validate Static Asset Integrity
-      run: scripts/verify-bundles.sh
-
-    - name: Run tests
-      if: ${{ ! matrix.coverage }}
-      run: python netbox/manage.py test netbox/ --parallel
-
-    - name: Run tests with coverage
-      if: ${{ matrix.coverage }}
-      run: coverage run netbox/manage.py test netbox/ --parallel
-
-    - name: Combine coverage data
-      if: ${{ matrix.coverage }}
-      run: coverage combine
-
-    - name: Show coverage report
-      if: ${{ matrix.coverage }}
-      run: coverage report
+      - name: Check out repo
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
+
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install coverage tblib
+
+      - name: Check for missing migrations
+        run: python netbox/manage.py makemigrations --check
+
+      # Copy frontend-generated files into STATIC_ROOT before SVG rendering
+      # tests read their CSS directly.
+      - name: Collect static files
+        run: python netbox/manage.py collectstatic --no-input
+
+      - name: Run tests
+        if: ${{ ! matrix.coverage }}
+        run: python netbox/manage.py test netbox/ --parallel
+
+      - name: Run tests with coverage
+        if: ${{ matrix.coverage }}
+        run: coverage run netbox/manage.py test netbox/ --parallel
+
+      - name: Combine coverage data
+        if: ${{ matrix.coverage }}
+        run: coverage combine
+
+      - name: Show coverage report
+        if: ${{ matrix.coverage }}
+        run: coverage report
+
+  frontend:
+    name: Frontend
+    needs: changes
+    if: needs.changes.result == 'success' && needs.changes.outputs.frontend == 'true'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out repo
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
+
+      - name: Use Node.js 20.x
+        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
+        with:
+          node-version: '20.x'
+
+      - name: Install Yarn Package Manager
+        run: npm install -g yarn
+
+      - name: Setup Node.js with Yarn Caching
+        uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
+        with:
+          node-version: '20.x'
+          cache: yarn
+          cache-dependency-path: netbox/project-static/yarn.lock
+
+      - name: Install Frontend Dependencies
+        run: yarn --cwd netbox/project-static
+
+      - name: Validate TypeScript and run ESLint
+        run: yarn --cwd netbox/project-static validate
+
+      - name: Validate formatting
+        run: yarn --cwd netbox/project-static validate:formatting
+
+      - name: Validate Static Asset Integrity
+        run: scripts/verify-bundles.sh
+
+  docs:
+    name: Documentation
+    needs: changes
+    if: needs.changes.result == 'success' && needs.changes.outputs.docs == 'true'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out repo
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
+
+      - name: Set up Python 3.12
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0
+        with:
+          python-version: '3.12'
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+
+      - name: Build documentation
+        run: zensical build

+ 20 - 0
AGENTS.md

@@ -273,6 +273,26 @@ GitHub Actions workflows in `.github/workflows/`:
 - Every PR must reference an approved GitHub issue.
 - Every PR must reference an approved GitHub issue.
 - PRs must include tests for new functionality.
 - PRs must include tests for new functionality.
 
 
+## PR Submission Requirements
+
+**Do not open a PR unless all the following conditions are met:**
+
+1. **Issue reference required** — The PR body must include a `Closes: #<number>` line identifying the associated GitHub issue. PRs without this line must not be submitted.
+2. **Issue must be open** — Before opening a PR, verify via `gh issue view <number>` that the referenced issue is currently open. Do not submit a PR against a closed issue.
+3. **Issue must be assigned to you** — Verify that the referenced issue is assigned to the submitting user. Do not open a PR for an issue that is unassigned or assigned to someone else.
+4. **No exceptions without maintainer status** — These three requirements are waived only for project maintainers (members of the `netboxlabs` GitHub organization). All other contributors must satisfy all three checks before a PR is opened.
+
+**Pre-submission checklist for AI agents:**
+
+```bash
+# Confirm the issue is open and assigned before opening a PR
+gh issue view <number> --json state,assignees
+```
+
+Reject the PR submission and report the problem if the issue is closed, unassigned, or assigned to a different user.
+
+Do not include an entry in the release notes for the PR unless explicitly instructed to do so. (Release notes are typically generated in aggregate as part of the release process to avoid merge conflicts.)
+
 ## Troubleshooting
 ## Troubleshooting
 
 
 - **Wrong directory for `manage.py`** — `manage.py` lives in `netbox/`, not the repo root. Always `cd netbox/` first or use the full path.
 - **Wrong directory for `manage.py`** — `manage.py` lives in `netbox/`, not the repo root. Always `cd netbox/` first or use the full path.

+ 0 - 3
CONTRIBUTING.md

@@ -102,9 +102,6 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
   * All tests pass when run with `NETBOX_CONFIGURATION=netbox.configuration_testing ./manage.py test`
   * All tests pass when run with `NETBOX_CONFIGURATION=netbox.configuration_testing ./manage.py test`
   * `ruff check` successfully validates style compliance
   * `ruff check` successfully validates style compliance
 
 
-> [!CAUTION]
-> Any contributions which include solely AI-generated content will be rejected. All PRs must be submitted by a human.
-
 * Some other tips to keep in mind:
 * Some other tips to keep in mind:
   * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
   * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
   * Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
   * Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.

+ 356 - 5
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.6.0",
+        "version": "4.6.1",
         "license": {
         "license": {
             "name": "Apache v2 License"
             "name": "Apache v2 License"
         }
         }
@@ -21611,6 +21611,169 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "notifications",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__empty",
+                        "schema": {
+                            "type": "boolean"
+                        }
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__ic",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__ie",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__iew",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__iregex",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__isw",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__nic",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__nie",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__niew",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__nisw",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "notifications__regex",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string",
+                                "x-spec-enum-id": "57071f4400340a5c"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "object_id",
                         "name": "object_id",
@@ -22087,15 +22250,55 @@
                         "in": "query",
                         "in": "query",
                         "name": "user",
                         "name": "user",
                         "schema": {
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "User name",
+                        "explode": true,
+                        "style": "form"
                     },
                     },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "user__n",
                         "name": "user__n",
                         "schema": {
                         "schema": {
-                            "type": "integer"
-                        }
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        },
+                        "description": "User name",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "user_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "nullable": true
+                            }
+                        },
+                        "description": "User (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "user_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "nullable": true
+                            }
+                        },
+                        "description": "User (ID)",
+                        "explode": true,
+                        "style": "form"
                     }
                     }
                 ],
                 ],
                 "tags": [
                 "tags": [
@@ -25037,6 +25240,71 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "cable_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Cable (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "cable_id__n",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer"
+                            }
+                        },
+                        "description": "Cable (ID)",
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "circuittermination_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "consoleport_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "consoleserverport_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "created",
                         "name": "created",
@@ -25136,6 +25404,19 @@
                             "format": "uuid"
                             "format": "uuid"
                         }
                         }
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "frontport_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "id",
                         "name": "id",
@@ -25221,6 +25502,19 @@
                         "explode": true,
                         "explode": true,
                         "style": "form"
                         "style": "form"
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "interface_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "in": "query",
                         "in": "query",
                         "name": "last_updated",
                         "name": "last_updated",
@@ -25347,6 +25641,58 @@
                             "type": "string"
                             "type": "string"
                         }
                         }
                     },
                     },
+                    {
+                        "in": "query",
+                        "name": "powerfeed_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "poweroutlet_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "powerport_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
+                    {
+                        "in": "query",
+                        "name": "rearport_id",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "integer",
+                                "format": "int32"
+                            }
+                        },
+                        "explode": true,
+                        "style": "form"
+                    },
                     {
                     {
                         "name": "start",
                         "name": "start",
                         "required": false,
                         "required": false,
@@ -275885,9 +276231,14 @@
                     "display": {
                     "display": {
                         "type": "string",
                         "type": "string",
                         "readOnly": true
                         "readOnly": true
+                    },
+                    "description": {
+                        "type": "string",
+                        "readOnly": true
                     }
                     }
                 },
                 },
                 "required": [
                 "required": [
+                    "description",
                     "device",
                     "device",
                     "display",
                     "display",
                     "face",
                     "face",

+ 4 - 0
docs/best-practices/performance-handbook.md

@@ -40,6 +40,10 @@ NetBox paginates large result sets to reduce the overall response size. The [`MA
 
 
 By default, NetBox restricts a GraphQL query to 10 aliases. Consider reducing this number by setting [`GRAPHQL_MAX_ALIASES`](../configuration/graphql-api.md#graphql_max_aliases) to a lower value.
 By default, NetBox restricts a GraphQL query to 10 aliases. Consider reducing this number by setting [`GRAPHQL_MAX_ALIASES`](../configuration/graphql-api.md#graphql_max_aliases) to a lower value.
 
 
+#### Limit GraphQL Query Depth
+
+Deeply nested GraphQL queries can impose substantial overhead, consuming undue server resources and increasing response times. Consider setting [`GRAPHQL_MAX_QUERY_DEPTH`](../configuration/graphql-api.md#graphql_max_query_depth) to limit the maximum nesting depth for any GraphQL query.
+
 #### Designate Isolated Deployments
 #### Designate Isolated Deployments
 
 
 If your NetBox installation does not have Internet access, set [`ISOLATED_DEPLOYMENT`](../configuration/system.md#isolated_deployment) to True. This will prevent the application from attempting routine external requests.
 If your NetBox installation does not have Internet access, set [`ISOLATED_DEPLOYMENT`](../configuration/system.md#isolated_deployment) to True. This will prevent the application from attempting routine external requests.

+ 10 - 0
docs/configuration/graphql-api.md

@@ -25,3 +25,13 @@ Setting this to `False` will disable the GraphQL API.
 Default: `10`
 Default: `10`
 
 
 The maximum number of queries that a GraphQL API request may contain.
 The maximum number of queries that a GraphQL API request may contain.
+
+---
+
+## GRAPHQL_MAX_QUERY_DEPTH
+
+!!! note "This parameter was introduced in NetBox v4.6.1."
+
+Default: `None` (no limit)
+
+The maximum allowed depth of any GraphQL query. When set to a positive integer, requests containing queries that exceed this depth will be rejected. Leaving this parameter unset (or setting it to `None` or `0`) disables query depth enforcement.

+ 1 - 1
docs/configuration/required-parameters.md

@@ -59,7 +59,7 @@ See the [`DATABASES`](#databases) configuration below for usage.
 
 
 ## DATABASES
 ## DATABASES
 
 
-NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
+NetBox requires access to a PostgreSQL 14 or later database service to store data. Note that support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7; PostgreSQL 15 or later will be required. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries:
 
 
 ```python
 ```python
 DATABASES = {
 DATABASES = {

+ 21 - 1
docs/configuration/system.md

@@ -57,7 +57,7 @@ In order to send email, NetBox needs an email server configured. The following i
 Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell:
 Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell:
 
 
 ```no-highlight
 ```no-highlight
-# python ./manage.py nbshell
+(venv) $ python3 ./manage.py nbshell
 >>> from django.core.mail import send_mail
 >>> from django.core.mail import send_mail
 >>> send_mail(
 >>> send_mail(
   'Test Email Subject',
   'Test Email Subject',
@@ -80,6 +80,26 @@ The hostname displayed in the user interface identifying the system on which Net
 
 
 ---
 ---
 
 
+## HTTP_CLIENT_IP_HEADERS
+
+!!! info "This parameter was introduced in NetBox v4.6.1."
+
+Default:
+
+```python
+(
+    'HTTP_X_REAL_IP',
+    'HTTP_X_FORWARDED_FOR',
+    'REMOTE_ADDR',
+)
+```
+
+An ordered list of HTTP request headers inspected to determine the source IP address of a client request. The first header in the list which is present on the request is used; if none are found, the client IP cannot be determined. This is most commonly required when NetBox is deployed behind a reverse proxy which injects a proprietary client IP header (e.g. `HTTP_CF_CONNECTING_IP` for Cloudflare).
+
+The client IP is used for source-address restrictions on API tokens and for logging failed login attempts.
+
+---
+
 ## HTTP_PROXIES
 ## HTTP_PROXIES
 
 
 Default: `None`
 Default: `None`

+ 32 - 0
docs/customization/custom-scripts.md

@@ -220,6 +220,38 @@ class DeviceConnectionsReport(Script):
                 self.log_success("Passed", device)
                 self.log_success("Passed", device)
 ```
 ```
 
 
+## Model Validation
+
+!!! warning "Validate objects before saving"
+    Direct ORM writes bypass validation normally performed by NetBox's UI and REST API.
+
+Custom scripts can create and update NetBox objects directly through Django's ORM. When doing so, instantiate the model, call `full_clean()`, and then call `save()`:
+
+```python
+obj = SomeModel(
+    field_a=value_a,
+    field_b=value_b,
+)
+
+obj.full_clean()
+obj.save()
+```
+
+Avoid using `Model.objects.create()` unless you intentionally want to skip model validation:
+
+```python
+SomeModel.objects.create(
+    field_a=value_a,
+    field_b=value_b,
+)
+```
+
+Django does not call `full_clean()` automatically when saving a model instance. Skipping validation can allow invalid or inconsistent data to be written to the database, which may later result in UI, API, or script errors.
+
+Bulk and direct queryset operations such as `bulk_create()`, `bulk_update()`, and `QuerySet.update()` should be used with the same care. These operations can bypass model validation and other model-specific save behavior.
+
+When editing an existing object, also see the change logging guidance below.
+
 ## Change Logging
 ## Change Logging
 
 
 To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.
 To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.

+ 3 - 0
docs/installation/1-postgresql.md

@@ -5,6 +5,9 @@ This section entails the installation and configuration of a local PostgreSQL da
 !!! warning "PostgreSQL 14 or later required"
 !!! warning "PostgreSQL 14 or later required"
     NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
     NetBox requires PostgreSQL 14 or later. Please note that MySQL and other relational databases are **not** supported.
 
 
+!!! warning "PostgreSQL 14 deprecation notice"
+    Support for PostgreSQL 14 is deprecated as of NetBox v4.6 and will be removed in NetBox v4.7. Please plan to upgrade to PostgreSQL 15 or later.
+
 ## Installation
 ## Installation
 
 
 ```no-highlight
 ```no-highlight

+ 31 - 2
docs/installation/index.md

@@ -28,12 +28,41 @@ The following sections detail how to set up a new instance of NetBox:
 | Dependency | Supported Versions |
 | Dependency | Supported Versions |
 |------------|--------------------|
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+                |
+| PostgreSQL | 14+ [^1]           |
 | Redis      | 4.0+               |
 | Redis      | 4.0+               |
 
 
+[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
+
 Below is a simplified overview of the NetBox application stack for reference:
 Below is a simplified overview of the NetBox application stack for reference:
 
 
-![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)
+```mermaid
+flowchart TB
+    nginx["<span style='color:#fff'><b>nginx / Apache</b><br/>HTTP reverse proxy</span>"]:::red
+    gunicorn["<span style='color:#fff'><b>gunicorn</b><br/>WSGI HTTP server</span>"]:::orange
+    rqworker["<span style='color:#fff'><b>rqworker</b><br/>Background worker</span>"]:::pink
+    netbox["<span style='color:#fff'><b>NetBox</b><br/>Django application</span>"]:::blue
+    django["<span style='color:#fff'><b>Django</b><br/>Python application framework</span>"]:::green
+    storage["<span style='color:#fff'><b>Storage Driver</b><br/>Static asset storage</span>"]:::gray
+    postgres["<span style='color:#fff'><b>PostgreSQL</b><br/>Relational database</span>"]:::teal
+    redis["<span style='color:#fff'><b>Redis</b><br/>In-memory store</span>"]:::purple
+
+    nginx --> gunicorn
+    nginx --> storage
+    gunicorn --> netbox
+    rqworker --> netbox
+    netbox --> django
+    django --> postgres
+    django --> redis
+
+    classDef red fill:#b91c1c,stroke:#7f1d1d,color:#fff
+    classDef orange fill:#c2410c,stroke:#7c2d12,color:#fff
+    classDef pink fill:#a21caf,stroke:#701a75,color:#fff
+    classDef blue fill:#1d4ed8,stroke:#1e3a8a,color:#fff
+    classDef green fill:#15803d,stroke:#14532d,color:#fff
+    classDef gray fill:#4b5563,stroke:#1f2937,color:#fff
+    classDef teal fill:#0f766e,stroke:#134e4a,color:#fff
+    classDef purple fill:#6d28d9,stroke:#4c1d95,color:#fff
+```
 
 
 ## Upgrading
 ## Upgrading
 
 

+ 18 - 2
docs/installation/upgrading.md

@@ -4,7 +4,20 @@ Upgrading NetBox to a new version is pretty simple, however users are cautioned
 
 
 NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change).
 NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change).
 
 
-[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png)
+```mermaid
+block-beta
+    columns 10
+    v29["v2.9"] v210["v2.10"] v211["v2.11"] v30["v3.0"] v31["v3.1"] dots["..."] v36["v3.6"] v37["v3.7"] v40["v4.0"] v41["v4.1"]
+    v2arrow["<span style='color:#fff'>To any v2.x release ➜</span>"]:3 space:7
+    space:2 v3arrow["<span style='color:#fff'>To any v3.x release ➜</span>"]:6 space:2
+    space:7 v4arrow["<span style='color:#fff'>To any v4.x release ➜</span>"]:3
+    classDef orange fill:#b45309,stroke:#78350f,color:#fff
+    classDef green fill:#0f766e,stroke:#134e4a,color:#fff
+    classDef blue fill:#1d4ed8,stroke:#1e3a8a,color:#fff
+    class v2arrow orange
+    class v3arrow green
+    class v4arrow blue
+```
 
 
 !!! warning "Perform a Backup"
 !!! warning "Perform a Backup"
     Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
     Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
@@ -20,13 +33,16 @@ NetBox requires the following dependencies:
 | Dependency | Supported Versions |
 | Dependency | Supported Versions |
 |------------|--------------------|
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+                |
+| PostgreSQL | 14+ [^1]           |
 | Redis      | 4.0+               |
 | Redis      | 4.0+               |
 
 
+[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
+
 ### Version History
 ### Version History
 
 
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 | NetBox Version | Python min | Python max | PostgreSQL min | Redis min |                                       Documentation                                       |
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
 |:--------------:|:----------:|:----------:|:--------------:|:---------:|:-----------------------------------------------------------------------------------------:|
+|      4.6       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.6.0/docs/installation/index.md) |
 |      4.5       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
 |      4.5       |    3.12    |    3.14    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.5.0/docs/installation/index.md) |
 |      4.4       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
 |      4.4       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.4.0/docs/installation/index.md) |
 |      4.3       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |
 |      4.3       |    3.10    |    3.12    |       14       |    4.0    | [Link](https://github.com/netbox-community/netbox/blob/v4.3.0/docs/installation/index.md) |

+ 4 - 1
docs/integrations/rest-api.md

@@ -792,7 +792,10 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
 
 
 #### v1 and v2 Tokens
 #### v1 and v2 Tokens
 
 
-Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens. Support for v1 tokens will be removed in a future NetBox release.
+!!! warning "v1 Tokens Are Deprecated"
+    v1 API tokens are deprecated as of NetBox v4.6 and will be removed in NetBox v5.0. All users should migrate to v2 tokens.
+
+Beginning with NetBox v4.5, two versions of API token are supported, denoted as v1 and v2. Users are strongly encouraged to create only v2 tokens and to discontinue the use of v1 tokens.
 
 
 v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved even with database-level access.
 v2 API tokens offer much stronger security. The token plaintext given at creation time is hashed together with a configured [cryptographic pepper](../configuration/required-parameters.md#api_token_peppers) to generate a unique checksum. This checksum is irreversible; the token plaintext is never stored on the server and thus cannot be retrieved even with database-level access.
 
 

+ 3 - 1
docs/introduction.md

@@ -79,5 +79,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
 | HTTP service       | nginx or Apache   |
 | HTTP service       | nginx or Apache   |
 | WSGI service       | gunicorn or uWSGI |
 | WSGI service       | gunicorn or uWSGI |
 | Application        | Django/Python     |
 | Application        | Django/Python     |
-| Database           | PostgreSQL 14+    |
+| Database           | PostgreSQL 14+ [^1] |
 | Task queuing       | Redis/django-rq   |
 | Task queuing       | Redis/django-rq   |
+
+[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.

binární
docs/media/installation/netbox_application_stack.png


binární
docs/media/installation/upgrade_paths.png


+ 7 - 0
docs/models/ipam/iprange.md

@@ -21,6 +21,13 @@ The [Virtual Routing and Forwarding](./vrf.md) instance in which this IP range e
 
 
 The beginning and ending IP addresses (inclusive) which define the boundaries of the range. Both IP addresses must specify the correct mask.
 The beginning and ending IP addresses (inclusive) which define the boundaries of the range. Both IP addresses must specify the correct mask.
 
 
+A range may contain a single IP address by setting the start and end address to the same value, including the same mask length. This is useful when a pool-like construct, such as a DHCP or NAT pool, contains only one usable address.
+
+!!! note
+    A single-address IP range is not a replacement for an [IP address](./ipaddress.md). Use an IP address object when modeling an address configured on an interface, assigned as a primary IP, or otherwise participating in IP-address-specific relationships. Use an IP range only when the address is being modeled as a pool, reservation range, or similar range-oriented construct.
+
+    The end address is still required. To create a single-address range, enter the same address and mask for both the start and end address.
+
 !!! note
 !!! note
     The maximum supported size of an IP range is 2^32 - 1.
     The maximum supported size of an IP range is 2^32 - 1.
 
 

+ 2 - 2
docs/models/users/token.md

@@ -2,13 +2,13 @@
 
 
 A token is a secret credential associated with a [user](./user.md) which authenticates requests to NetBox's REST and GraphQL APIs. A user may hold multiple tokens; each can be independently expired, restricted, or revoked.
 A token is a secret credential associated with a [user](./user.md) which authenticates requests to NetBox's REST and GraphQL APIs. A user may hold multiple tokens; each can be independently expired, restricted, or revoked.
 
 
-Beginning with NetBox v4.5, two token versions are supported. v2 tokens (the default for newly-created tokens) are stored only as a salted HMAC digest, and the plaintext is shown to the user only once at creation time. Legacy v1 tokens store the plaintext directly and are retained for backward compatibility; their use is discouraged. See the [REST API authentication](../../integrations/rest-api.md#authentication) documentation for the request header formats used by each version.
+Beginning with NetBox v4.5, two token versions are supported. v2 tokens (the default for newly-created tokens) are stored only as a salted HMAC digest, and the plaintext is shown to the user only once at creation time. Legacy v1 tokens store the plaintext directly; **their use is deprecated and support will be removed in NetBox v5.0.** See the [REST API authentication](../../integrations/rest-api.md#authentication) documentation for the request header formats used by each version.
 
 
 ## Fields
 ## Fields
 
 
 ### Version
 ### Version
 
 
-Indicates whether this is a v1 (legacy) or v2 token. v2 is the default and is strongly preferred.
+Indicates whether this is a v1 (legacy) or v2 token. v2 is the default and is strongly preferred. **v1 tokens are deprecated and will be removed in NetBox v5.0.**
 
 
 ### User
 ### User
 
 

+ 32 - 9
docs/plugins/installation.md

@@ -7,12 +7,32 @@
 
 
 Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment.
 Download and install the plugin's Python package per its installation instructions. Plugins published via PyPI are typically installed using the [`pip`](https://packaging.python.org/en/latest/tutorials/installing-packages/) command line utility. Be sure to install the plugin within NetBox's virtual environment.
 
 
+For production installations, the recommended method is to add the package to `/opt/netbox/local_requirements.txt` and then run NetBox's upgrade script. This installs the plugin as part of the standard NetBox installation and upgrade process and ensures that it will be reinstalled if the virtual environment is rebuilt.
+
+```no-highlight
+$ sudo sh -c "echo '<package>' >> /opt/netbox/local_requirements.txt"
+$ sudo /opt/netbox/upgrade.sh
+```
+
+Installing packages into NetBox's virtual environment requires write permissions to that directory. For installations under `/opt/netbox`, a regular user typically does not have write permissions. Activating the virtual environment does not change file permissions, so a direct `pip install` command may result in a `Permission denied` error.
+
+If you must install a package manually, use one of the following methods. You can switch to a root shell before activating the virtual environment:
+
 ```no-highlight
 ```no-highlight
-$ source /opt/netbox/venv/bin/activate
-(venv) $ pip install <package>
+$ sudo -i
+# source /opt/netbox/venv/bin/activate
+(venv) # pip install <package>
 ```
 ```
 
 
-Alternatively, you may wish to install the plugin manually by running `python setup.py install`. If you are developing a plugin and want to install it only temporarily, run `python setup.py develop` instead.
+Or, run `pip` by invoking the Python executable within NetBox's virtual environment:
+
+```no-highlight
+$ sudo /opt/netbox/venv/bin/python3 -m pip install <package>
+```
+
+In the examples above, `$` indicates a regular user shell and `#` indicates a root shell.
+
+Packages that are not published to PyPI may need to be installed from a local source tree. From the package directory, use one of the methods above to run `pip install .`; for editable development installs, run `pip install --editable .` instead.
 
 
 ## Enable the Plugin
 ## Enable the Plugin
 
 
@@ -38,13 +58,16 @@ PLUGINS_CONFIG = {
 }
 }
 ```
 ```
 
 
+!!! note
+    If you ran `/opt/netbox/upgrade.sh` after enabling and configuring the plugin, the script has already applied database migrations and collected static files. If you ran it only to install the package before enabling the plugin, continue with the migration and static file steps below.
+
 ## Run Database Migrations
 ## Run Database Migrations
 
 
 If the plugin introduces new database models, run the provided schema migrations:
 If the plugin introduces new database models, run the provided schema migrations:
 
 
 ```no-highlight
 ```no-highlight
-(venv) $ cd /opt/netbox/netbox/
-(venv) $ python3 manage.py migrate
+(venv) $ cd /opt/netbox/
+(venv) $ python3 netbox/manage.py migrate
 ```
 ```
 
 
 !!! tip
 !!! tip
@@ -55,14 +78,14 @@ If the plugin introduces new database models, run the provided schema migrations
 Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
 Plugins may package static resources like images or scripts to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
 
 
 ```no-highlight
 ```no-highlight
-(venv) $ cd /opt/netbox/netbox/
-(venv) $ python3 manage.py collectstatic
+(venv) $ cd /opt/netbox/
+(venv) $ python3 netbox/manage.py collectstatic
 ```
 ```
 
 
-### Restart WSGI Service
+## Restart WSGI Service
 
 
 Finally, restart the WSGI service and RQ workers to load the new plugin:
 Finally, restart the WSGI service and RQ workers to load the new plugin:
 
 
 ```no-highlight
 ```no-highlight
-# sudo systemctl restart netbox netbox-rq
+$ sudo systemctl restart netbox netbox-rq
 ```
 ```

+ 8 - 7
docs/plugins/removal.md

@@ -19,18 +19,19 @@ Delete the plugin's entry (if any) in the `PLUGINS_CONFIG` dictionary in `config
 Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin.
 Run the `reindex` management command to reindex the global search engine. This will remove any stale entries pertaining to objects provided by the plugin.
 
 
 ```no-highlight
 ```no-highlight
-$ cd /opt/netbox/netbox/
+$ cd /opt/netbox/
 $ source /opt/netbox/venv/bin/activate
 $ source /opt/netbox/venv/bin/activate
-(venv) $ python3 manage.py reindex
+(venv) $ python3 netbox/manage.py reindex
 ```
 ```
 
 
 ## Uninstall its Python Package
 ## Uninstall its Python Package
 
 
-Use `pip` to remove the installed plugin:
+If the plugin was installed by adding it to `/opt/netbox/local_requirements.txt`, remove its package entry from that file first. Otherwise, the package will be reinstalled the next time NetBox's upgrade script is run.
+
+Use `pip` to remove the installed plugin from NetBox's virtual environment:
 
 
 ```no-highlight
 ```no-highlight
-$ source /opt/netbox/venv/bin/activate
-(venv) $ pip uninstall <package>
+$ sudo /opt/netbox/venv/bin/python3 -m pip uninstall <package>
 ```
 ```
 
 
 ## Restart WSGI Service
 ## Restart WSGI Service
@@ -38,7 +39,7 @@ $ source /opt/netbox/venv/bin/activate
 Restart the WSGI service:
 Restart the WSGI service:
 
 
 ```no-highlight
 ```no-highlight
-# sudo systemctl restart netbox
+$ sudo systemctl restart netbox
 ```
 ```
 
 
 ## Drop Database Tables
 ## Drop Database Tables
@@ -150,5 +151,5 @@ stale_types.delete()
 After making these changes, restart the NetBox service to ensure all changes are reflected.
 After making these changes, restart the NetBox service to ensure all changes are reflected.
 
 
 ```no-highlight
 ```no-highlight
-sudo systemctl restart netbox
+$ sudo systemctl restart netbox
 ```
 ```

+ 44 - 0
docs/release-notes/version-4.6.md

@@ -1,5 +1,49 @@
 # NetBox v4.6
 # NetBox v4.6
 
 
+## v4.6.1 (2026-05-19)
+
+### Enhancements
+
+* [#16851](https://github.com/netbox-community/netbox/issues/16851) - Correct errant and missing ARIA labels throughout the UI
+* [#20776](https://github.com/netbox-community/netbox/issues/20776) - Add changelog message support for bulk rename operations
+* [#20808](https://github.com/netbox-community/netbox/issues/20808) - Display the names of installed devices when selecting a rack position
+* [#21938](https://github.com/netbox-community/netbox/issues/21938) - Display geographic hierarchy for circuit terminations assigned to sites, locations, or regions
+* [#21993](https://github.com/netbox-community/netbox/issues/21993) - Allow IP ranges comprising a single IP address
+* [#22057](https://github.com/netbox-community/netbox/issues/22057) - Add filter support for notifications and subscriptions to GraphQL API
+* [#22192](https://github.com/netbox-community/netbox/issues/22192) - Introduce `HTTP_CLIENT_IP_HEADERS` configuration parameter to customize HTTP headers used to determine client IP address
+
+### Performance Improvements
+
+* [#22060](https://github.com/netbox-community/netbox/issues/22060) - Implement GraphQL query depth limiting (via `GRAPHQL_MAX_QUERY_DEPTH`) to guard against excessively complex queries
+* [#22061](https://github.com/netbox-community/netbox/issues/22061) - Add prefetch hints to various GraphQL type mixins to improve query efficiency
+* [#22102](https://github.com/netbox-community/netbox/issues/22102) - Add GIN index on CablePath to optimize filtering of cable paths by node
+* [#22104](https://github.com/netbox-community/netbox/issues/22104) - Avoid retracing cable paths during cable deletion
+* [#22146](https://github.com/netbox-community/netbox/issues/22146) - Avoid renumbering MPTT trees when creating module bays
+
+### Bug Fixes
+
+* [#21934](https://github.com/netbox-community/netbox/issues/21934) - Fix striped table rows overriding conditional row color highlighting for virtual/LAG interfaces
+* [#22055](https://github.com/netbox-community/netbox/issues/22055) - Fix API exceptions being silently consumed by middleware without reporting to Sentry
+* [#22079](https://github.com/netbox-community/netbox/issues/22079) - Fix security vulnerability allowing arbitrary code execution via ExportTemplate `environment_params` (CVE-2026-29514)
+* [#22081](https://github.com/netbox-community/netbox/issues/22081) - REST API should return plaintext for new v2 tokens upon creation
+* [#22183](https://github.com/netbox-community/netbox/issues/22183) - Fix spurious changelog entries for `interface_b` generated when saving an unchanged wireless link
+* [#22190](https://github.com/netbox-community/netbox/issues/22190) - Restore tenant and tenant group column options for circuits group table configuration
+* [#22198](https://github.com/netbox-community/netbox/issues/22198) - Restrict export template queryset to authorized objects in REST API and list views
+* [#22202](https://github.com/netbox-community/netbox/issues/22202) - Fix crash in system housekeeping job when no stable releases are available
+* [#22206](https://github.com/netbox-community/netbox/issues/22206) - Fix `TypeError` exception raised by table config validation when `ordering` attribute is null
+* [#22207](https://github.com/netbox-community/netbox/issues/22207) - Fix missing explicit `object_type` field annotation on TableConfigType GraphQL type
+* [#22208](https://github.com/netbox-community/netbox/issues/22208) - Add missing `user_id` FK filter on job filterset
+* [#22209](https://github.com/netbox-community/netbox/issues/22209) - Add missing `cable_id` FK filter on cable termination filterset
+* [#22227](https://github.com/netbox-community/netbox/issues/22227) - Fix display of IP address detail view when multiple NAT assignments exist
+* [#22236](https://github.com/netbox-community/netbox/issues/22236) - Fix support for user changelog message when saving table configurations via the REST API
+
+### Deprecations
+
+* [#22128](https://github.com/netbox-community/netbox/issues/22128) - Deprecate support for v1 API tokens (to be removed in v5.0)
+* [#22141](https://github.com/netbox-community/netbox/issues/22141) - Deprecate support for PostgreSQL 14 (to be removed in v4.7)
+
+---
+
 ## v4.6.0 (2026-05-05)
 ## v4.6.0 (2026-05-05)
 
 
 ### New Features
 ### New Features

+ 16 - 6
netbox/circuits/forms/model_forms.py

@@ -1,6 +1,6 @@
 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
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from circuits.choices import (
 from circuits.choices import (
@@ -24,7 +24,7 @@ from utilities.forms.fields import (
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
 from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
-from utilities.templatetags.builtins.filters import bettertitle
+from utilities.string import title
 
 
 __all__ = (
 __all__ = (
     'CircuitForm',
     'CircuitForm',
@@ -195,13 +195,11 @@ class CircuitTerminationForm(NetBoxModelForm):
     termination_type = ContentTypeChoiceField(
     termination_type = ContentTypeChoiceField(
         queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
         queryset=ContentType.objects.filter(model__in=CIRCUIT_TERMINATION_TERMINATION_TYPES),
         widget=HTMXSelect(),
         widget=HTMXSelect(),
-        required=False,
         label=_('Termination type')
         label=_('Termination type')
     )
     )
     termination = DynamicModelChoiceField(
     termination = DynamicModelChoiceField(
         label=_('Termination'),
         label=_('Termination'),
         queryset=Site.objects.none(),  # Initial queryset
         queryset=Site.objects.none(),  # Initial queryset
-        required=False,
         disabled=True,
         disabled=True,
         selector=True
         selector=True
     )
     )
@@ -247,16 +245,28 @@ class CircuitTerminationForm(NetBoxModelForm):
                 self.fields['termination'].queryset = model.objects.all()
                 self.fields['termination'].queryset = model.objects.all()
                 self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
                 self.fields['termination'].widget.attrs['selector'] = model._meta.label_lower
                 self.fields['termination'].disabled = False
                 self.fields['termination'].disabled = False
-                self.fields['termination'].label = _(bettertitle(model._meta.verbose_name))
+                self.fields['termination'].label = _(title(model._meta.verbose_name))
             except ObjectDoesNotExist:
             except ObjectDoesNotExist:
                 pass
                 pass
 
 
             if self.instance and termination_type_id != self.instance.termination_type_id:
             if self.instance and termination_type_id != self.instance.termination_type_id:
                 self.initial['termination'] = None
                 self.initial['termination'] = None
+        else:
+            # Clear the initial termination value if termination_type is not set
+            self.initial['termination'] = None
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        termination = self.cleaned_data.get('termination')
+        termination_type = self.cleaned_data.get('termination_type')
+        if termination_type and not termination:
+            raise ValidationError({
+                'termination': _('Please select a {termination_type}.').format(
+                    termination_type=_(title(termination_type.model_class()._meta.verbose_name))
+                )
+            })
+
         # Assign the selected termination (if any)
         # Assign the selected termination (if any)
         self.instance.termination = self.cleaned_data.get('termination')
         self.instance.termination = self.cleaned_data.get('termination')
 
 
@@ -319,7 +329,7 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
                 self.fields['member'].queryset = model.objects.all()
                 self.fields['member'].queryset = model.objects.all()
                 self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
                 self.fields['member'].widget.attrs['selector'] = model._meta.label_lower
                 self.fields['member'].disabled = False
                 self.fields['member'].disabled = False
-                self.fields['member'].label = _(bettertitle(model._meta.verbose_name))
+                self.fields['member'].label = _(title(model._meta.verbose_name))
             except ObjectDoesNotExist:
             except ObjectDoesNotExist:
                 pass
                 pass
 
 

+ 2 - 2
netbox/circuits/graphql/types.py

@@ -74,7 +74,7 @@ class ProviderNetworkType(PrimaryObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
 class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType):
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
     circuit: Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='termination')
     def termination(self) -> Annotated[
     def termination(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -133,7 +133,7 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
     group: Annotated['CircuitGroupType', strawberry.lazy('circuits.graphql.types')]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='member')
     def member(self) -> Annotated[
     def member(self) -> Annotated[
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         Annotated['CircuitType', strawberry.lazy('circuits.graphql.types')]
         | Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')],
         | Annotated['VirtualCircuitType', strawberry.lazy('circuits.graphql.types')],

+ 8 - 0
netbox/circuits/models/circuits.py

@@ -17,6 +17,7 @@ from netbox.models.features import (
     TagsMixin,
     TagsMixin,
 )
 )
 from netbox.models.mixins import DistanceMixin
 from netbox.models.mixins import DistanceMixin
+from utilities.string import title
 
 
 from .base import BaseCircuitType
 from .base import BaseCircuitType
 
 
@@ -367,6 +368,13 @@ class CircuitTermination(
         return reverse('circuits:circuittermination', args=[self.pk])
         return reverse('circuits:circuittermination', args=[self.pk])
 
 
     def clean(self):
     def clean(self):
+        if self.termination_type and not (self.termination or self.termination_id):
+            termination_type = self.termination_type.model_class()
+            raise ValidationError(
+                _("Please select a {termination_type}.").format(
+                    termination_type=_(title(termination_type._meta.verbose_name))
+                )
+            )
         super().clean()
         super().clean()
 
 
         if self.termination is None:
         if self.termination is None:

+ 3 - 3
netbox/circuits/tables/circuits.py

@@ -159,7 +159,7 @@ class CircuitTerminationTable(NetBoxTable):
         )
         )
 
 
 
 
-class CircuitGroupTable(OrganizationalModelTable):
+class CircuitGroupTable(TenancyColumnsMixin, OrganizationalModelTable):
     name = tables.Column(
     name = tables.Column(
         verbose_name=_('Name'),
         verbose_name=_('Name'),
         linkify=True
         linkify=True
@@ -176,8 +176,8 @@ class CircuitGroupTable(OrganizationalModelTable):
     class Meta(OrganizationalModelTable.Meta):
     class Meta(OrganizationalModelTable.Meta):
         model = CircuitGroup
         model = CircuitGroup
         fields = (
         fields = (
-            'pk', 'name', 'description', 'circuit_group_assignment_count', 'comments', 'tags',
-            'created', 'last_updated', 'actions',
+            'pk', 'name', 'description', 'circuit_group_assignment_count', 'tenant', 'tenant_group', 'comments',
+            'tags', 'created', 'last_updated', 'actions',
         )
         )
         default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
         default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
 
 

+ 12 - 12
netbox/circuits/tests/test_api.py

@@ -8,7 +8,7 @@ from ipam.models import ASN, RIR
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 
 
-class AppTest(APITestCase):
+class AppTestCase(APITestCase):
 
 
     def test_root(self):
     def test_root(self):
         url = reverse('circuits-api:api-root')
         url = reverse('circuits-api:api-root')
@@ -17,7 +17,7 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class ProviderTest(APIViewTestCases.APIViewTestCase):
+class ProviderTestCase(APIViewTestCases.APIViewTestCase):
     model = Provider
     model = Provider
     brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -59,7 +59,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
+class CircuitTypeTestCase(APIViewTestCases.APIViewTestCase):
     model = CircuitType
     model = CircuitType
     brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = (
     create_data = (
@@ -91,7 +91,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
         CircuitType.objects.bulk_create(circuit_types)
         CircuitType.objects.bulk_create(circuit_types)
 
 
 
 
-class CircuitTest(APIViewTestCases.APIViewTestCase):
+class CircuitTestCase(APIViewTestCases.APIViewTestCase):
     model = Circuit
     model = Circuit
     brief_fields = ['cid', 'description', 'display', 'id', 'provider', 'url']
     brief_fields = ['cid', 'description', 'display', 'id', 'provider', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -155,7 +155,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
+class CircuitTerminationTestCase(APIViewTestCases.APIViewTestCase):
     model = CircuitTermination
     model = CircuitTermination
     brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
     brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
     user_permissions = ('circuits.view_circuit', )
     user_permissions = ('circuits.view_circuit', )
@@ -217,7 +217,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class CircuitGroupTest(APIViewTestCases.APIViewTestCase):
+class CircuitGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = CircuitGroup
     model = CircuitGroup
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -249,7 +249,7 @@ class CircuitGroupTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
+class ProviderAccountTestCase(APIViewTestCases.APIViewTestCase):
     model = ProviderAccount
     model = ProviderAccount
     brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
     brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
     user_permissions = ('circuits.view_provider',)
     user_permissions = ('circuits.view_provider',)
@@ -293,7 +293,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
+class CircuitGroupAssignmentTestCase(APIViewTestCases.APIViewTestCase):
     model = CircuitGroupAssignment
     model = CircuitGroupAssignment
     brief_fields = ['display', 'group', 'id', 'member', 'member_id', 'member_type', 'priority', 'url']
     brief_fields = ['display', 'group', 'id', 'member', 'member_id', 'member_type', 'priority', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -368,7 +368,7 @@ class CircuitGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
+class ProviderNetworkTestCase(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
     model = ProviderNetwork
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     user_permissions = ('circuits.view_provider', )
     user_permissions = ('circuits.view_provider', )
@@ -409,7 +409,7 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class VirtualCircuitTypeTest(APIViewTestCases.APIViewTestCase):
+class VirtualCircuitTypeTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualCircuitType
     model = VirtualCircuitType
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'virtual_circuit_count']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'virtual_circuit_count']
     create_data = (
     create_data = (
@@ -441,7 +441,7 @@ class VirtualCircuitTypeTest(APIViewTestCases.APIViewTestCase):
         VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
         VirtualCircuitType.objects.bulk_create(virtual_circuit_types)
 
 
 
 
-class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
+class VirtualCircuitTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualCircuit
     model = VirtualCircuit
     brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
     brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -505,7 +505,7 @@ class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
+class VirtualCircuitTerminationTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualCircuitTermination
     model = VirtualCircuitTermination
     brief_fields = ['description', 'display', 'id', 'interface', 'role', 'url', 'virtual_circuit']
     brief_fields = ['description', 'display', 'id', 'interface', 'role', 'url', 'virtual_circuit']
     bulk_update_data = {
     bulk_update_data = {

+ 43 - 0
netbox/circuits/tests/test_forms.py

@@ -0,0 +1,43 @@
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from circuits.forms import CircuitTerminationForm
+from circuits.models import Circuit, CircuitType, Provider, ProviderNetwork
+
+
+class CircuitTerminationFormTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+        cls.circuit = Circuit.objects.create(
+            cid='Circuit 1',
+            provider=provider,
+            type=circuit_type,
+        )
+        cls.provider_network = ProviderNetwork.objects.create(
+            name='Provider Network 1',
+            provider=provider,
+        )
+
+    def test_termination_required_when_termination_type_is_selected(self):
+        """
+        Selecting a termination type without a target object should report a
+        validation error against the visible form field.
+        """
+        provider_network_type = ContentType.objects.get_for_model(ProviderNetwork)
+
+        form = CircuitTerminationForm(
+            data={
+                'circuit': self.circuit.pk,
+                'term_side': 'A',
+                'termination_type': provider_network_type.pk,
+                'termination': '',
+            }
+        )
+
+        self.assertFalse(form.is_valid())
+        self.assertIn('termination', form.errors)
+        self.assertIn('Please select a Provider Network.', form.errors['termination'])
+        self.assertNotIn('termination_id', form.errors)

+ 20 - 0
netbox/circuits/tests/test_models.py

@@ -1,3 +1,5 @@
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
@@ -146,3 +148,21 @@ class CircuitTerminationTestCase(TestCase):
 
 
         # Cache should be cleared (SET_NULL behavior)
         # Cache should be cleared (SET_NULL behavior)
         self.assertIsNone(self.circuits[0].termination_a)
         self.assertIsNone(self.circuits[0].termination_a)
+
+    def test_termination_required_when_termination_type_is_selected(self):
+        """Model rejects type-without-target before generic GFK validation hits termination_id."""
+        provider_network_type = ContentType.objects.get_for_model(ProviderNetwork)
+
+        termination = CircuitTermination(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination_type=provider_network_type,
+        )
+
+        with self.assertRaises(ValidationError) as cm:
+            termination.full_clean()
+
+        errors = cm.exception.message_dict
+        self.assertIn(NON_FIELD_ERRORS, errors)
+        self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
+        self.assertNotIn('termination_id', errors)

+ 92 - 0
netbox/circuits/tests/test_signals.py

@@ -0,0 +1,92 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.test import SimpleTestCase, TestCase
+
+from circuits import signals
+from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from dcim.models import (
+    Cable,
+    CablePath,
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    Manufacturer,
+    Site,
+)
+
+
+class RebuildCablepathsSignalTestCase(TestCase):
+    """
+    Verify circuits.signals.rebuild_cablepaths retraces paths that cross the peer termination
+    when a CircuitTermination is saved or deleted.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site', slug='site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        cls.device = Device.objects.create(site=cls.site, device_type=device_type, role=device_role, name='Device 1')
+        provider = Provider.objects.create(name='Provider', slug='provider')
+        circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
+        cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
+
+    def test_saving_termination_rebuilds_peer_path(self):
+        interface = Interface.objects.create(device=self.device, name='Interface 1')
+        site_z = Site.objects.create(name='Site Z', slug='site-z')
+        termination_a = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        termination_z = CircuitTermination.objects.create(circuit=self.circuit, termination=site_z, term_side='Z')
+        Cable(a_terminations=[interface], b_terminations=[termination_a]).save()
+        original_path = CablePath.objects.get()
+
+        # Saving the Z (peer) termination should cause rebuild_paths to run for the A peer.
+        with patch('circuits.signals.rebuild_paths') as rebuild_paths:
+            termination_z.save()
+
+        rebuild_paths.assert_called_once()
+        rebuilt_for = rebuild_paths.call_args.args[0]
+        self.assertEqual([t.pk for t in rebuilt_for], [termination_a.pk])
+
+        # Without patching, the real signal should retrace the path successfully.
+        termination_z.save()
+        self.assertEqual(CablePath.objects.count(), 1)
+        self.assertNotEqual(CablePath.objects.get().pk, original_path.pk)
+
+    def test_deleting_termination_rebuilds_peer_path(self):
+        site_z = Site.objects.create(name='Site Z', slug='site-z')
+        termination_a = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+        termination_z = CircuitTermination.objects.create(circuit=self.circuit, termination=site_z, term_side='Z')
+
+        with patch('circuits.signals.rebuild_paths') as rebuild_paths:
+            termination_z.delete()
+
+        rebuild_paths.assert_called_once()
+        rebuilt_for = rebuild_paths.call_args.args[0]
+        self.assertEqual([t.pk for t in rebuilt_for], [termination_a.pk])
+
+    def test_saving_termination_without_peer_does_not_rebuild(self):
+        termination = CircuitTermination.objects.create(circuit=self.circuit, termination=self.site, term_side='A')
+
+        with patch('circuits.signals.rebuild_paths') as rebuild_paths:
+            termination.save()
+
+        rebuild_paths.assert_not_called()
+
+
+class RebuildCablepathsDirectHandlerTestCase(SimpleTestCase):
+    """
+    Direct-call tests for rebuild_cablepaths branches that are not reachable through
+    normal model operations (e.g. raw=True is only set by Django's loaddata pathway).
+    """
+
+    def test_raw_import_skips_peer_lookup_and_rebuild(self):
+        instance = SimpleNamespace(get_peer_termination=MagicMock())
+
+        with patch.object(signals, 'rebuild_paths') as rebuild_paths:
+            signals.rebuild_cablepaths(instance=instance, raw=True)
+
+        instance.get_peer_termination.assert_not_called()
+        rebuild_paths.assert_not_called()

+ 11 - 11
netbox/circuits/tests/test_tables.py

@@ -2,45 +2,45 @@ from circuits.tables import *
 from utilities.testing import TableTestCases
 from utilities.testing import TableTestCases
 
 
 
 
-class CircuitTypeTableTest(TableTestCases.StandardTableTestCase):
+class CircuitTypeTableTestCase(TableTestCases.StandardTableTestCase):
     table = CircuitTypeTable
     table = CircuitTypeTable
 
 
 
 
-class CircuitTableTest(TableTestCases.StandardTableTestCase):
+class CircuitTableTestCase(TableTestCases.StandardTableTestCase):
     table = CircuitTable
     table = CircuitTable
 
 
 
 
-class CircuitTerminationTableTest(TableTestCases.StandardTableTestCase):
+class CircuitTerminationTableTestCase(TableTestCases.StandardTableTestCase):
     table = CircuitTerminationTable
     table = CircuitTerminationTable
 
 
 
 
-class CircuitGroupTableTest(TableTestCases.StandardTableTestCase):
+class CircuitGroupTableTestCase(TableTestCases.StandardTableTestCase):
     table = CircuitGroupTable
     table = CircuitGroupTable
 
 
 
 
-class CircuitGroupAssignmentTableTest(TableTestCases.StandardTableTestCase):
+class CircuitGroupAssignmentTableTestCase(TableTestCases.StandardTableTestCase):
     table = CircuitGroupAssignmentTable
     table = CircuitGroupAssignmentTable
 
 
 
 
-class ProviderTableTest(TableTestCases.StandardTableTestCase):
+class ProviderTableTestCase(TableTestCases.StandardTableTestCase):
     table = ProviderTable
     table = ProviderTable
 
 
 
 
-class ProviderAccountTableTest(TableTestCases.StandardTableTestCase):
+class ProviderAccountTableTestCase(TableTestCases.StandardTableTestCase):
     table = ProviderAccountTable
     table = ProviderAccountTable
 
 
 
 
-class ProviderNetworkTableTest(TableTestCases.StandardTableTestCase):
+class ProviderNetworkTableTestCase(TableTestCases.StandardTableTestCase):
     table = ProviderNetworkTable
     table = ProviderNetworkTable
 
 
 
 
-class VirtualCircuitTypeTableTest(TableTestCases.StandardTableTestCase):
+class VirtualCircuitTypeTableTestCase(TableTestCases.StandardTableTestCase):
     table = VirtualCircuitTypeTable
     table = VirtualCircuitTypeTable
 
 
 
 
-class VirtualCircuitTableTest(TableTestCases.StandardTableTestCase):
+class VirtualCircuitTableTestCase(TableTestCases.StandardTableTestCase):
     table = VirtualCircuitTable
     table = VirtualCircuitTable
 
 
 
 
-class VirtualCircuitTerminationTableTest(TableTestCases.StandardTableTestCase):
+class VirtualCircuitTerminationTableTestCase(TableTestCases.StandardTableTestCase):
     table = VirtualCircuitTerminationTable
     table = VirtualCircuitTerminationTable

+ 18 - 4
netbox/circuits/tests/test_views.py

@@ -1,7 +1,6 @@
 import datetime
 import datetime
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 
 
 from circuits.choices import *
 from circuits.choices import *
@@ -210,8 +209,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertContains(response, circuit_type.name)
         self.assertContains(response, circuit_type.name)
         self.assertContains(response, 'background-color: #12ab34')
         self.assertContains(response, 'background-color: #12ab34')
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_terminations(self):
     def test_bulk_import_objects_with_terminations(self):
+        self.add_permissions(
+            'circuits.view_circuit',
+            'circuits.view_provider',
+            'circuits.view_circuittype',
+            'dcim.view_site',
+        )
         site = Site.objects.first()
         site = Site.objects.first()
         json_data = f"""
         json_data = f"""
             [
             [
@@ -420,8 +424,13 @@ class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'circuits.view_circuittermination',
+            'dcim.view_cable',
+            'dcim.view_interface',
+            'dcim.view_device',
+        )
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
 
 
         circuittermination = CircuitTermination.objects.first()
         circuittermination = CircuitTermination.objects.first()
@@ -711,8 +720,13 @@ class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_terminations(self):
     def test_bulk_import_objects_with_terminations(self):
+        self.add_permissions(
+            'circuits.view_virtualcircuit',
+            'circuits.view_providernetwork',
+            'circuits.view_virtualcircuittype',
+            'dcim.view_interface',
+        )
         interfaces = Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL)
         interfaces = Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL)
         json_data = f"""
         json_data = f"""
             [
             [

+ 31 - 2
netbox/circuits/ui/panels.py

@@ -12,16 +12,43 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
 
 
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     title = _('Termination')
     title = _('Termination')
+    termination_ancestor_max_depth = 3
 
 
     def __init__(self, side, accessor=None, **kwargs):
     def __init__(self, side, accessor=None, **kwargs):
         super().__init__(accessor=accessor, **kwargs)
         super().__init__(accessor=accessor, **kwargs)
         self.side = side
         self.side = side
 
 
+    def _get_termination_nodes(self, termination):
+        """
+        Return the termination target's ancestors, including itself, when the
+        target is tree-like.
+
+        Non-tree GFK targets return None, so the template preserves the current
+        single-object rendering.
+        """
+        target = getattr(termination, 'termination', None)
+        if target is None:
+            return None
+
+        get_ancestors = getattr(target, 'get_ancestors', None)
+        if not callable(get_ancestors):
+            return None
+
+        nodes = list(get_ancestors(include_self=True))
+
+        if self.termination_ancestor_max_depth is not None:
+            nodes = nodes[-self.termination_ancestor_max_depth:]
+
+        return nodes
+
     def get_context(self, context):
     def get_context(self, context):
+        termination = resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}')
+
         return {
         return {
             **super().get_context(context),
             **super().get_context(context),
             'side': self.side,
             'side': self.side,
-            'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
+            'termination': termination,
+            'termination_nodes': self._get_termination_nodes(termination),
         }
         }
 
 
 
 
@@ -58,7 +85,9 @@ class CircuitTerminationPanel(panels.ObjectAttributesPanel):
     title = _('Circuit Termination')
     title = _('Circuit Termination')
     circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
     circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
     provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
     provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
-    termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
+    termination = attrs.GenericForeignKeyAttr(
+        'termination', linkify=True, nested=True, max_depth=3, label=_('Termination point')
+    )
     connection = attrs.TemplatedAttr(
     connection = attrs.TemplatedAttr(
         'pk',
         'pk',
         template_name='circuits/circuit_termination/attrs/connection.html',
         template_name='circuits/circuit_termination/attrs/connection.html',

+ 1 - 1
netbox/core/apps.py

@@ -22,7 +22,7 @@ class CoreConfig(AppConfig):
 
 
     def ready(self):
     def ready(self):
         from core.api import schema  # noqa: F401
         from core.api import schema  # noqa: F401
-        from core.checks import check_duplicate_indexes  # noqa: F401
+        from core.checks import check_duplicate_indexes, check_postgresql_version  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox import context_managers  # noqa: F401
         from netbox.models.features import register_models
         from netbox.models.features import register_models
 
 

+ 29 - 1
netbox/core/checks.py

@@ -1,9 +1,11 @@
 from django.apps import apps
 from django.apps import apps
-from django.core.checks import Error, Tags, register
+from django.core.checks import Error, Tags, Warning, register
+from django.db import connection
 from django.db.models import Index, UniqueConstraint
 from django.db.models import Index, UniqueConstraint
 
 
 __all__ = (
 __all__ = (
     'check_duplicate_indexes',
     'check_duplicate_indexes',
+    'check_postgresql_version',
 )
 )
 
 
 
 
@@ -39,3 +41,29 @@ def check_duplicate_indexes(app_configs, **kwargs):
                 )
                 )
 
 
     return errors
     return errors
+
+
+@register(Tags.database)
+def check_postgresql_version(app_configs, **kwargs):
+    """
+    Warn if the PostgreSQL version is less than 15, as support for PostgreSQL 14
+    will be removed in NetBox v4.7.
+    """
+    warnings = []
+    try:
+        with connection.cursor() as cursor:
+            cursor.execute('SHOW server_version_num')
+            row = cursor.fetchone()
+            pg_version = int(row[0])
+        if pg_version < 150000:
+            major_version = pg_version // 10000
+            warnings.append(
+                Warning(
+                    f'Support for PostgreSQL {major_version} is deprecated and will be removed in NetBox v4.7.',
+                    hint='Please upgrade to PostgreSQL 15 or later.',
+                    id='netbox.W001',
+                )
+            )
+    except Exception:
+        pass
+    return warnings

+ 17 - 0
netbox/core/filtersets.py

@@ -137,7 +137,24 @@ class JobFilterSet(BaseFilterSet):
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    notifications = django_filters.MultipleChoiceFilter(
+        choices=JobNotificationChoices,
+        distinct=False,
+        null_value=None
+    )
     queue_name = django_filters.CharFilter()
     queue_name = django_filters.CharFilter()
+    user_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=User.objects.all(),
+        distinct=False,
+        label=_('User (ID)'),
+    )
+    user = django_filters.ModelMultipleChoiceFilter(
+        field_name='user__username',
+        queryset=User.objects.all(),
+        distinct=False,
+        to_field_name='username',
+        label=_('User name'),
+    )
 
 
     class Meta:
     class Meta:
         model = Job
         model = Job

+ 4 - 1
netbox/core/jobs.py

@@ -222,8 +222,11 @@ class SystemHousekeepingJob(JobRunner):
             if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
             if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
                 continue
                 continue
             releases.append((version.parse(release['tag_name']), release.get('html_url')))
             releases.append((version.parse(release['tag_name']), release.get('html_url')))
-        latest_release = max(releases)
         self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
         self.logger.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
+        if not releases:
+            self.logger.info("No usable releases found; skipping")
+            return
+        latest_release = max(releases)
         self.logger.info(f"Latest release: {latest_release[0]}")
         self.logger.info(f"Latest release: {latest_release[0]}")
 
 
         # Cache the most recent release
         # Cache the most recent release

+ 1 - 1
netbox/core/management/commands/nbshell.py

@@ -150,7 +150,7 @@ class Command(BaseCommand):
         try:
         try:
             import readline
             import readline
             import rlcompleter
             import rlcompleter
-        except ModuleNotFoundError:
+        except ModuleNotFoundError:  # pragma: no cover
             pass
             pass
         else:
         else:
             readline.set_completer(rlcompleter.Completer(namespace).complete)
             readline.set_completer(rlcompleter.Completer(namespace).complete)

+ 4 - 4
netbox/core/tests/test_api.py

@@ -17,7 +17,7 @@ from utilities.testing.utils import disable_logging
 from ..models import *
 from ..models import *
 
 
 
 
-class AppTest(APITestCase):
+class AppTestCase(APITestCase):
 
 
     def test_root(self):
     def test_root(self):
         url = reverse('core-api:api-root')
         url = reverse('core-api:api-root')
@@ -26,7 +26,7 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class DataSourceTest(APIViewTestCases.APIViewTestCase):
+class DataSourceTestCase(APIViewTestCases.APIViewTestCase):
     model = DataSource
     model = DataSource
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -62,7 +62,7 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class DataFileTest(
+class DataFileTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.GraphQLTestCase
     APIViewTestCases.GraphQLTestCase
@@ -105,7 +105,7 @@ class DataFileTest(
         DataFile.objects.bulk_create(data_files)
         DataFile.objects.bulk_create(data_files)
 
 
 
 
-class ObjectTypeTest(APITestCase):
+class ObjectTypeTestCase(APITestCase):
 
 
     def test_list_objects(self):
     def test_list_objects(self):
         object_type_count = ObjectType.objects.count()
         object_type_count = ObjectType.objects.count()

+ 3 - 3
netbox/core/tests/test_changelog.py

@@ -33,7 +33,7 @@ from utilities.testing.utils import create_tags, create_test_device, post_data
 from utilities.testing.views import ModelViewTestCase
 from utilities.testing.views import ModelViewTestCase
 
 
 
 
-class ChangeLogViewTest(ModelViewTestCase):
+class ChangeLogViewTestCase(ModelViewTestCase):
     model = Site
     model = Site
 
 
     @classmethod
     @classmethod
@@ -397,7 +397,7 @@ class ChangeLogViewTest(ModelViewTestCase):
         self.assertEqual(objectchanges.count(), 2)
         self.assertEqual(objectchanges.count(), 2)
 
 
 
 
-class ChangeLogAPITest(APITestCase):
+class ChangeLogAPITestCase(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -703,7 +703,7 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
         self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
 
 
 
 
-class ChangelogPruneRetentionTest(TestCase):
+class ChangelogPruneRetentionTestCase(TestCase):
     """Test suite for Changelog pruning retention settings."""
     """Test suite for Changelog pruning retention settings."""
 
 
     @staticmethod
     @staticmethod

+ 2 - 2
netbox/core/tests/test_data_backends.py

@@ -12,7 +12,7 @@ except ImportError:
     DULWICH_AVAILABLE = False
     DULWICH_AVAILABLE = False
 
 
 
 
-class URLEmbeddedCredentialsTests(TestCase):
+class URLEmbeddedCredentialsTestCase(TestCase):
     def test_url_with_embedded_username(self):
     def test_url_with_embedded_username(self):
         self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
         self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
 
 
@@ -54,7 +54,7 @@ class URLEmbeddedCredentialsTests(TestCase):
 
 
 
 
 @skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
 @skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
-class GitBackendCredentialIntegrationTests(TestCase):
+class GitBackendCredentialIntegrationTestCase(TestCase):
     """
     """
     Integration tests that verify GitBackend correctly applies credential logic.
     Integration tests that verify GitBackend correctly applies credential logic.
 
 

+ 50 - 0
netbox/core/tests/test_filtersets.py

@@ -244,6 +244,56 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
 
 
 
+class JobTestCase(TestCase, BaseFilterSetTests):
+    queryset = Job.objects.all()
+    filterset = JobFilterSet
+    ignore_fields = ('data', 'error', 'log_entries')
+
+    @classmethod
+    def setUpTestData(cls):
+        users = (
+            User(username='user1'),
+            User(username='user2'),
+            User(username='user3'),
+        )
+        User.objects.bulk_create(users)
+
+        jobs = (
+            Job(
+                name='Job 1', job_id=uuid.uuid4(), user=users[0],
+                notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+            ),
+            Job(
+                name='Job 2', job_id=uuid.uuid4(), user=users[0],
+                notifications=JobNotificationChoices.NOTIFICATION_ALWAYS,
+            ),
+            Job(
+                name='Job 3', job_id=uuid.uuid4(), user=users[1],
+                notifications=JobNotificationChoices.NOTIFICATION_ON_FAILURE,
+            ),
+            Job(
+                name='Job 4', job_id=uuid.uuid4(), user=users[2],
+                notifications=JobNotificationChoices.NOTIFICATION_NEVER,
+            ),
+        )
+        Job.objects.bulk_create(jobs)
+
+    def test_user(self):
+        """Filter Jobs by user (ID and username)."""
+        params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+        params = {'user': ['user1', 'user2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+    def test_notifications(self):
+        """Filter Jobs by notification policy."""
+        params = {'notifications': [
+            JobNotificationChoices.NOTIFICATION_ALWAYS,
+            JobNotificationChoices.NOTIFICATION_ON_FAILURE,
+        ]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+
 class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
 class ObjectTypeTestCase(TestCase, BaseFilterSetTests):
     queryset = ObjectType.objects.all()
     queryset = ObjectType.objects.all()
     filterset = ObjectTypeFilterSet
     filterset = ObjectTypeFilterSet

+ 387 - 0
netbox/core/tests/test_jobs.py

@@ -0,0 +1,387 @@
+import uuid
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+import requests
+from django.conf import settings
+from django.test import TestCase, override_settings
+from django.utils import timezone
+
+from core.choices import DataSourceStatusChoices
+from core.jobs import SyncDataSourceJob, SystemHousekeepingJob
+from core.models import DataFile, DataSource, Job
+
+
+def _make_runner(cls, **job_attrs):
+    """
+    Build a JobRunner without going through ``__init__``.
+
+    ``JobRunner.__init__`` attaches a ``JobLogHandler`` to a module-level
+    singleton logger, so calling it once per test would accumulate handlers
+    across the suite. Bypass it and stub the logger directly.
+    """
+    runner = cls.__new__(cls)
+    runner.job = MagicMock(**job_attrs)
+    runner.logger = MagicMock()
+    return runner
+
+
+class HousekeepingRunnerMixin:
+    """Provides a `_runner()` helper that builds a SystemHousekeepingJob with a mock job."""
+
+    @staticmethod
+    def _runner():
+        return _make_runner(SystemHousekeepingJob)
+
+
+class SyncDataSourceJobTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.datasource = DataSource.objects.create(
+            name='Test Source',
+            type='local',
+            source_url='/tmp/test',
+        )
+
+    def test_enqueue_sets_datasource_status_to_queued(self):
+        job = MagicMock()
+        job.object = self.datasource
+
+        with patch('core.models.Job.enqueue', return_value=job):
+            result = SyncDataSourceJob.enqueue(instance=self.datasource)
+
+        self.assertIs(result, job)
+        # Verify both the in-memory assignment (`datasource.status = ...`) and the
+        # persisted update (`DataSource.objects.filter(pk=...).update(...)`).
+        self.assertEqual(self.datasource.status, DataSourceStatusChoices.QUEUED)
+        self.datasource.refresh_from_db()
+        self.assertEqual(self.datasource.status, DataSourceStatusChoices.QUEUED)
+
+    def test_enqueue_without_object_is_noop(self):
+        # Baseline: the datasource starts at NEW. (Captures setUpTestData drift.)
+        self.assertEqual(self.datasource.status, DataSourceStatusChoices.NEW)
+
+        job = MagicMock()
+        job.object = None
+
+        with (
+            patch('core.models.Job.enqueue', return_value=job),
+            patch(
+                'core.jobs.DataSource.objects.filter',
+                wraps=DataSource.objects.filter,
+            ) as filter_,
+        ):
+            result = SyncDataSourceJob.enqueue()
+
+        self.assertIs(result, job)
+        # Intent: the `if datasource := job.object` branch was skipped, so no
+        # filter().update() call was even attempted.
+        filter_.assert_not_called()
+        # Outcome: no side effect on any existing DataSource.
+        self.datasource.refresh_from_db()
+        self.assertEqual(self.datasource.status, DataSourceStatusChoices.NEW)
+
+    def test_run_syncs_datasource_and_updates_search_cache(self):
+        datafile = DataFile.objects.create(
+            source=self.datasource,
+            path='test.txt',
+            last_updated=timezone.now(),
+            size=4,
+            hash='0' * 64,
+            data=b'test',
+        )
+        runner = _make_runner(SyncDataSourceJob, object_id=self.datasource.pk)
+
+        with (
+            patch('core.models.DataSource.sync') as sync,
+            patch('core.jobs.search_backend.cache') as cache,
+        ):
+            runner.run()
+
+        sync.assert_called_once_with()
+        cache.assert_called_once()
+        # The cache argument should iterate over the datasource's data files.
+        cache_arg = cache.call_args.args[0]
+        self.assertEqual(list(cache_arg), [datafile])
+
+    def test_run_marks_datasource_failed_and_reraises_on_sync_error(self):
+        runner = _make_runner(SyncDataSourceJob, object_id=self.datasource.pk)
+
+        with (
+            patch('core.models.DataSource.sync', side_effect=RuntimeError('boom')),
+            patch('core.jobs.search_backend.cache') as cache,
+        ):
+            with self.assertRaisesMessage(RuntimeError, 'boom'):
+                runner.run()
+
+        self.datasource.refresh_from_db()
+        self.assertEqual(self.datasource.status, DataSourceStatusChoices.FAILED)
+        cache.assert_not_called()
+
+    def test_run_raises_when_datasource_no_longer_exists(self):
+        # If the DataSource was deleted between enqueue and run, the initial lookup
+        # raises DoesNotExist; the framework will surface it as a job error.
+        runner = _make_runner(SyncDataSourceJob, object_id=99_999_999)
+
+        with self.assertRaises(DataSource.DoesNotExist):
+            runner.run()
+
+
+class SystemHousekeepingRunTestCase(HousekeepingRunnerMixin, TestCase):
+    SUBMETHODS = (
+        'send_census_report',
+        'clear_expired_sessions',
+        'prune_changelog',
+        'delete_expired_jobs',
+        'check_for_new_releases',
+    )
+
+    @override_settings(DEBUG=True)
+    def test_run_skips_when_debug_is_enabled(self):
+        # DEBUG is checked before sys.argv; sys.argv is irrelevant here.
+        runner = self._runner()
+        patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
+        mocks = {name: p.start() for name, p in patches.items()}
+        self.addCleanup(lambda: [p.stop() for p in patches.values()])
+
+        runner.run()
+
+        for mock in mocks.values():
+            mock.assert_not_called()
+
+    @override_settings(DEBUG=False)
+    def test_run_skips_during_test_invocation(self):
+        runner = self._runner()
+        patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
+        mocks = {name: p.start() for name, p in patches.items()}
+        self.addCleanup(lambda: [p.stop() for p in patches.values()])
+
+        with patch('core.jobs.sys.argv', ['manage.py', 'test']):
+            runner.run()
+
+        for mock in mocks.values():
+            mock.assert_not_called()
+
+    @override_settings(DEBUG=False)
+    def test_run_executes_all_housekeeping_tasks(self):
+        runner = self._runner()
+        patches = {name: patch.object(SystemHousekeepingJob, name) for name in self.SUBMETHODS}
+        mocks = {name: p.start() for name, p in patches.items()}
+        self.addCleanup(lambda: [p.stop() for p in patches.values()])
+
+        with patch('core.jobs.sys.argv', ['netbox']):
+            runner.run()
+
+        for mock in mocks.values():
+            mock.assert_called_once_with()
+
+
+class SendCensusReportTestCase(HousekeepingRunnerMixin, TestCase):
+    @override_settings(ISOLATED_DEPLOYMENT=True)
+    def test_send_census_report_skips_when_isolated_deployment(self):
+        with patch('core.jobs.requests.get') as get:
+            self._runner().send_census_report()
+        get.assert_not_called()
+
+    @override_settings(ISOLATED_DEPLOYMENT=False, CENSUS_REPORTING_ENABLED=False)
+    def test_send_census_report_skips_when_reporting_disabled(self):
+        with patch('core.jobs.requests.get') as get:
+            self._runner().send_census_report()
+        get.assert_not_called()
+
+    @override_settings(
+        ISOLATED_DEPLOYMENT=False,
+        CENSUS_REPORTING_ENABLED=True,
+        CENSUS_URL='https://census.example/',
+        DEPLOYMENT_ID='abc123',
+    )
+    def test_send_census_report_sends_expected_payload(self):
+        with (
+            patch('core.jobs.requests.get') as get,
+            patch('core.jobs.resolve_proxies', return_value={'https': 'proxy'}) as resolve,
+        ):
+            self._runner().send_census_report()
+
+        resolve.assert_called_once_with(url='https://census.example/')
+        get.assert_called_once()
+        kwargs = get.call_args.kwargs
+        self.assertEqual(kwargs['url'], 'https://census.example/')
+        self.assertEqual(kwargs['timeout'], 3)
+        self.assertEqual(kwargs['proxies'], {'https': 'proxy'})
+        self.assertEqual(kwargs['params']['deployment_id'], 'abc123')
+        self.assertIn('version', kwargs['params'])
+        self.assertIn('python_version', kwargs['params'])
+
+    @override_settings(
+        ISOLATED_DEPLOYMENT=False,
+        CENSUS_REPORTING_ENABLED=True,
+        CENSUS_URL='https://census.example/',
+    )
+    def test_send_census_report_swallows_request_exception(self):
+        with (
+            patch('core.jobs.requests.get', side_effect=requests.RequestException('down')),
+            patch('core.jobs.resolve_proxies', return_value={}),
+        ):
+            self._runner().send_census_report()  # must not raise
+
+
+class ClearExpiredSessionsTestCase(HousekeepingRunnerMixin, TestCase):
+    def test_clear_expired_sessions_calls_session_store(self):
+        engine = MagicMock()
+        with patch('core.jobs.import_module', return_value=engine) as import_module:
+            self._runner().clear_expired_sessions()
+
+        import_module.assert_called_once_with(settings.SESSION_ENGINE)
+        engine.SessionStore.clear_expired.assert_called_once_with()
+
+    def test_clear_expired_sessions_handles_not_implemented(self):
+        engine = MagicMock()
+        engine.SessionStore.clear_expired.side_effect = NotImplementedError
+        runner = self._runner()
+
+        with patch('core.jobs.import_module', return_value=engine):
+            runner.clear_expired_sessions()  # must not raise
+
+        runner.logger.warning.assert_called_once()
+        self.assertIn(
+            'does not support',
+            runner.logger.warning.call_args.args[0],
+        )
+
+
+class DeleteExpiredJobsTestCase(HousekeepingRunnerMixin, TestCase):
+    def test_delete_expired_jobs_skips_when_retention_unset(self):
+        old_job = Job.objects.create(name='old', job_id=uuid.uuid4())
+        Job.objects.filter(pk=old_job.pk).update(created=timezone.now() - timedelta(days=365))
+
+        with patch('core.jobs.Config') as config_cls:
+            config_cls.return_value.JOB_RETENTION = 0
+            self._runner().delete_expired_jobs()
+
+        self.assertTrue(Job.objects.filter(pk=old_job.pk).exists())
+
+    def test_delete_expired_jobs_deletes_only_jobs_older_than_retention(self):
+        old_job = Job.objects.create(name='old', job_id=uuid.uuid4())
+        recent_job = Job.objects.create(name='recent', job_id=uuid.uuid4())
+        Job.objects.filter(pk=old_job.pk).update(created=timezone.now() - timedelta(days=30))
+        Job.objects.filter(pk=recent_job.pk).update(created=timezone.now() - timedelta(hours=1))
+
+        with patch('core.jobs.Config') as config_cls:
+            config_cls.return_value.JOB_RETENTION = 7
+            self._runner().delete_expired_jobs()
+
+        self.assertFalse(Job.objects.filter(pk=old_job.pk).exists())
+        self.assertTrue(Job.objects.filter(pk=recent_job.pk).exists())
+
+
+class CheckForNewReleasesTestCase(HousekeepingRunnerMixin, TestCase):
+    @override_settings(ISOLATED_DEPLOYMENT=True)
+    def test_check_for_new_releases_skips_when_isolated(self):
+        with patch('core.jobs.requests.get') as get:
+            self._runner().check_for_new_releases()
+        get.assert_not_called()
+
+    @override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL=None)
+    def test_check_for_new_releases_skips_when_url_unset(self):
+        with patch('core.jobs.requests.get') as get:
+            self._runner().check_for_new_releases()
+        get.assert_not_called()
+
+    @override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL='https://api.example/')
+    def test_check_for_new_releases_handles_request_exception(self):
+        with (
+            patch('core.jobs.requests.get', side_effect=requests.RequestException('down')),
+            patch('core.jobs.cache') as cache,
+            patch('core.jobs.resolve_proxies', return_value={}),
+        ):
+            self._runner().check_for_new_releases()
+
+        cache.set.assert_not_called()
+
+    @override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL='https://api.example/')
+    def test_check_for_new_releases_handles_no_stable_releases(self):
+        # All entries are filtered out (prereleases, devreleases, or missing tag_name);
+        # check_for_new_releases() must not crash on the resulting empty list.
+        response = MagicMock()
+        response.json.return_value = [
+            {'tag_name': 'v4.7.0-rc1', 'html_url': 'https://example/rc', 'prerelease': True},
+            {'tag_name': 'v4.7.0-dev', 'html_url': 'https://example/dev', 'devrelease': True},
+            {'html_url': 'https://example/no-tag'},
+        ]
+
+        with (
+            patch('core.jobs.requests.get', return_value=response),
+            patch('core.jobs.cache.set') as cache_set,
+            patch('core.jobs.resolve_proxies', return_value={}),
+        ):
+            self._runner().check_for_new_releases()
+
+        cache_set.assert_not_called()
+
+    @override_settings(ISOLATED_DEPLOYMENT=False, RELEASE_CHECK_URL='https://api.example/')
+    def test_check_for_new_releases_caches_latest_stable_release(self):
+        response = MagicMock()
+        response.json.return_value = [
+            {'tag_name': 'v4.5.0', 'html_url': 'https://example/4.5.0'},
+            {'tag_name': 'v4.6.0', 'html_url': 'https://example/4.6.0'},
+            {'tag_name': 'v4.7.0-rc1', 'html_url': 'https://example/rc', 'prerelease': True},
+            {'tag_name': 'v4.7.0-dev', 'html_url': 'https://example/dev', 'devrelease': True},
+            {'html_url': 'https://example/no-tag'},
+        ]
+
+        with (
+            patch('core.jobs.requests.get', return_value=response) as get,
+            patch('core.jobs.cache.set') as cache_set,
+            patch('core.jobs.resolve_proxies', return_value={'http': 'proxy'}) as resolve,
+        ):
+            self._runner().check_for_new_releases()
+
+        # HTTP request: URL, GitHub API Accept header, resolved proxies.
+        resolve.assert_called_once_with(url='https://api.example/')
+        get.assert_called_once_with(
+            url='https://api.example/',
+            headers={'Accept': 'application/vnd.github.v3+json'},
+            proxies={'http': 'proxy'},
+        )
+        response.raise_for_status.assert_called_once_with()
+
+        cache_set.assert_called_once()
+        # Accept either positional or keyword form for cache.set(key, value, ttl).
+        call = cache_set.call_args
+        bound = {**dict(zip(('key', 'value', 'timeout'), call.args)), **call.kwargs}
+        self.assertEqual(bound['key'], 'latest_release')
+        self.assertIsNone(bound['timeout'])
+        latest_version, latest_url = bound['value']
+        self.assertEqual(str(latest_version), '4.6.0')
+        self.assertEqual(latest_url, 'https://example/4.6.0')
+
+
+class PruneChangelogTestCase(HousekeepingRunnerMixin, TestCase):
+    def test_prune_changelog_skips_when_retention_unset(self):
+        with (
+            patch('core.jobs.Config') as config_cls,
+            patch('core.jobs.ObjectChange') as object_change,
+        ):
+            config_cls.return_value.CHANGELOG_RETENTION = None
+            self._runner().prune_changelog()
+
+        object_change.objects.filter.assert_not_called()
+
+    def test_prune_changelog_uses_strict_cutoff_filter(self):
+        # Implementation pin: prune_changelog must use time__lt (strict less-than) so a
+        # record exactly at the cutoff is retained. End-to-end behavior of the prune is
+        # covered by ChangelogPruneRetentionTestCase in core/tests/test_changelog.py.
+        with (
+            patch('core.jobs.Config') as config_cls,
+            patch('core.jobs.ObjectChange') as object_change,
+            patch('core.jobs.timezone') as tz,
+        ):
+            config_cls.return_value.CHANGELOG_RETENTION = 7
+            config_cls.return_value.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = False
+            tz.now.return_value = timezone.datetime(2026, 1, 8, tzinfo=timezone.get_current_timezone())
+            object_change.objects.filter.return_value.delete.return_value = (0, {})
+
+            self._runner().prune_changelog()
+
+        expected_cutoff = timezone.datetime(2026, 1, 1, tzinfo=timezone.get_current_timezone())
+        object_change.objects.filter.assert_called_once_with(time__lt=expected_cutoff)

+ 79 - 0
netbox/core/tests/test_management_command_coverage.py

@@ -0,0 +1,79 @@
+import ast
+from pathlib import Path
+
+from django.apps import apps
+from django.conf import settings
+from django.test import SimpleTestCase
+
+EXCLUDED_CUSTOM_COMMANDS = {
+    # Deprecated; excluded from management command test coverage by #22124.
+    'housekeeping',
+}
+
+
+class ManagementCommandCoverageTestCase(SimpleTestCase):
+    def test_all_custom_management_commands_have_tests(self):
+        custom_commands = self._get_custom_management_commands()
+        tested_commands = self._get_tested_management_commands()
+
+        self.assertTrue(
+            custom_commands,
+            'No custom management commands were discovered; check command discovery logic.',
+        )
+
+        missing_commands = sorted(custom_commands - tested_commands - EXCLUDED_CUSTOM_COMMANDS)
+
+        self.assertEqual(
+            missing_commands,
+            [],
+            msg=(f'Tests are missing for custom management commands: {", ".join(missing_commands)}'),
+        )
+
+    @staticmethod
+    def _get_custom_management_commands():
+        base_dir = Path(settings.BASE_DIR).resolve()
+        commands = set()
+
+        for app_config in apps.get_app_configs():
+            app_path = Path(app_config.path).resolve()
+            if not app_path.is_relative_to(base_dir):
+                continue
+
+            commands_path = app_path / 'management' / 'commands'
+            if not commands_path.exists():
+                continue
+
+            commands.update(path.stem for path in commands_path.glob('*.py') if not path.name.startswith('_'))
+
+        return commands
+
+    @staticmethod
+    def _get_tested_management_commands():
+        base_dir = Path(settings.BASE_DIR).resolve()
+        commands = set()
+
+        for test_file in base_dir.glob('*/tests/test_management_commands.py'):
+            tree = ast.parse(test_file.read_text(encoding='utf-8'))
+            for node in ast.walk(tree):
+                if not isinstance(node, ast.Call):
+                    continue
+                if not _is_call_command(node.func):
+                    continue
+                if not node.args:
+                    continue
+
+                command_name = node.args[0]
+                if isinstance(command_name, ast.Constant) and isinstance(command_name.value, str):
+                    commands.add(command_name.value)
+
+        return commands
+
+
+def _is_call_command(func):
+    if isinstance(func, ast.Name):
+        return func.id == 'call_command'
+
+    if isinstance(func, ast.Attribute):
+        return func.attr == 'call_command'
+
+    return False

+ 317 - 0
netbox/core/tests/test_management_commands.py

@@ -0,0 +1,317 @@
+from io import StringIO
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.test import TestCase, override_settings
+
+from core.choices import DataSourceStatusChoices
+from core.management.commands import nbshell
+from core.management.commands.rqworker import DEFAULT_QUEUES
+
+
+class MakeMigrationsTestCase(TestCase):
+    @override_settings(DEVELOPER=False)
+    def test_blocked_in_non_developer_mode(self):
+        with self.assertRaisesMessage(CommandError, 'development purposes only'):
+            call_command('makemigrations', stdout=StringIO(), stderr=StringIO())
+
+    @override_settings(DEVELOPER=False)
+    def test_check_flag_allowed_in_non_developer_mode(self):
+        with patch('core.management.commands.makemigrations._Command.handle') as super_handle:
+            call_command(
+                'makemigrations',
+                check_changes=True,
+                stdout=StringIO(),
+                stderr=StringIO(),
+            )
+
+        super_handle.assert_called_once()
+        self.assertTrue(super_handle.call_args.kwargs['check_changes'])
+
+
+class NbShellTestCase(TestCase):
+    def test_color_helpers_wrap_text(self):
+        self.assertIn('message', nbshell.color('green', 'message'))
+        self.assertIn('message', nbshell.bright('message'))
+
+    def test_get_models_excludes_private_models(self):
+        public_model = type('PublicModel', (), {})
+        private_model = type('PrivateModel', (), {'_netbox_private': True})
+        app_config = SimpleNamespace(get_models=lambda: [public_model, private_model])
+
+        self.assertEqual(nbshell.get_models(app_config), [public_model])
+
+    def test_get_constants_returns_module_attributes(self):
+        constants = SimpleNamespace(FOO='bar', ANSWER=42)
+
+        with patch('core.management.commands.nbshell.import_string', return_value=constants):
+            self.assertEqual(
+                nbshell.get_constants(SimpleNamespace(name='testapp')),
+                {'FOO': 'bar', 'ANSWER': 42},
+            )
+
+    def test_get_constants_handles_missing_constants_module(self):
+        with patch('core.management.commands.nbshell.import_string', side_effect=ImportError):
+            self.assertEqual(nbshell.get_constants(SimpleNamespace(name='testapp')), {})
+
+    def test_executes_inline_command(self):
+        namespace = {}
+
+        with patch(
+            'core.management.commands.nbshell.Command.get_namespace',
+            return_value=namespace,
+        ):
+            call_command('nbshell', command='answer = 42')
+
+        self.assertEqual(namespace['answer'], 42)
+
+    def test_starts_interactive_shell_without_inline_command(self):
+        namespace = {'answer': 42}
+
+        with (
+            patch('core.management.commands.nbshell.Command.get_namespace', return_value=namespace),
+            patch('core.management.commands.nbshell.Command.get_banner_text', return_value='banner'),
+            patch('core.management.commands.nbshell.code.interact', return_value=None) as interact,
+        ):
+            call_command('nbshell', stdout=StringIO())
+
+        interact.assert_called_once_with(banner='banner', local=namespace)
+
+    def test_get_namespace_includes_models_constants_and_helpers(self):
+        class DummyModel:
+            pass
+
+        app_config = SimpleNamespace(
+            name='dummyapp',
+            get_models=lambda: [DummyModel],
+        )
+        command = nbshell.Command()
+        command.django_models = {}
+
+        with (
+            patch('core.management.commands.nbshell.CORE_APPS', ('dummyapp',)),
+            patch('core.management.commands.nbshell.get_installed_plugins', return_value={}),
+            patch('core.management.commands.nbshell.apps.get_app_config', return_value=app_config),
+            patch('core.management.commands.nbshell.get_constants', return_value={'CONSTANT': 'value'}),
+        ):
+            namespace = command.get_namespace()
+
+        self.assertIs(namespace['dummyapp'].DummyModel, DummyModel)
+        self.assertEqual(namespace['dummyapp'].CONSTANT, 'value')
+        self.assertEqual(command.django_models['dummyapp'], ['DummyModel'])
+        self.assertEqual(namespace['lsapps'], command._lsapps)
+        self.assertEqual(namespace['lsmodels'], command._lsmodels)
+
+    def test_list_apps_and_models_helpers(self):
+        command = nbshell.Command()
+        command.django_models = {'dcim': ['Device', 'Site']}
+        app_config = SimpleNamespace(verbose_name='DCIM')
+
+        with (
+            patch('core.management.commands.nbshell.apps.get_app_config', return_value=app_config),
+            patch('builtins.print') as print_,
+        ):
+            command._lsapps()
+            command._lsmodels('dcim')
+
+        self.assertIn(('dcim - DCIM',), [call.args for call in print_.call_args_list])
+        self.assertIn(('DCIM:',), [call.args for call in print_.call_args_list])
+        self.assertIn(('  dcim.Device',), [call.args for call in print_.call_args_list])
+        self.assertIn(('  dcim.Site',), [call.args for call in print_.call_args_list])
+
+    def test_list_models_reports_unknown_app(self):
+        command = nbshell.Command()
+        command.django_models = {}
+
+        with patch('builtins.print') as print_:
+            command._lsmodels('unknown')
+
+        print_.assert_called_once_with('No models listed for unknown')
+
+    def test_list_models_lists_all_apps_when_no_app_label_given(self):
+        command = nbshell.Command()
+        command.django_models = {'dcim': ['Device'], 'ipam': ['IPAddress']}
+        app_configs = {
+            'dcim': SimpleNamespace(verbose_name='DCIM'),
+            'ipam': SimpleNamespace(verbose_name='IPAM'),
+        }
+
+        with (
+            patch(
+                'core.management.commands.nbshell.apps.get_app_config',
+                side_effect=lambda label: app_configs[label],
+            ),
+            patch('builtins.print') as print_,
+        ):
+            command._lsmodels()
+
+        printed = [call.args for call in print_.call_args_list]
+        self.assertIn(('DCIM:',), printed)
+        self.assertIn(('IPAM:',), printed)
+        self.assertIn(('  dcim.Device',), printed)
+        self.assertIn(('  ipam.IPAddress',), printed)
+
+    def test_banner_includes_installed_plugins(self):
+        with (
+            patch('core.management.commands.nbshell.platform.node', return_value='netbox'),
+            patch('core.management.commands.nbshell.platform.python_version', return_value='3.12.0'),
+            patch('core.management.commands.nbshell.get_version', return_value='5.2.0'),
+            patch('core.management.commands.nbshell.get_installed_plugins', return_value={'plugin': '1.2.3'}),
+        ):
+            banner = nbshell.Command.get_banner_text()
+
+        self.assertIn('NetBox interactive shell', banner)
+        self.assertIn('Plugins:', banner)
+        self.assertIn('plugin', banner)
+
+
+class RQWorkerTestCase(TestCase):
+    def test_defaults_to_all_queues_and_enables_scheduler(self):
+        with (
+            patch('core.management.commands.rqworker.registry', {'system_jobs': {}}),
+            patch('core.management.commands.rqworker._Command.handle') as super_handle,
+            self.assertLogs('netbox.rqworker', level='WARNING') as logs,
+        ):
+            call_command('rqworker', stdout=StringIO(), stderr=StringIO())
+
+        super_handle.assert_called_once()
+        args, kwargs = super_handle.call_args
+        self.assertEqual(args, DEFAULT_QUEUES)
+        self.assertTrue(kwargs['with_scheduler'])
+        self.assertEqual(len(logs.output), 1)
+        self.assertIn('No queues have been specified', logs.output[0])
+
+    def test_schedules_registered_system_jobs(self):
+        job = MagicMock()
+        job.name = 'TestJob'
+
+        with (
+            patch('core.management.commands.rqworker.registry', {'system_jobs': {job: {'interval': 5}}}),
+            patch('core.management.commands.rqworker._Command.handle') as super_handle,
+        ):
+            call_command('rqworker', 'high', stdout=StringIO(), stderr=StringIO())
+
+        job.enqueue_once.assert_called_once_with(interval=5)
+        super_handle.assert_called_once()
+        args, kwargs = super_handle.call_args
+        self.assertEqual(args, ('high',))
+        self.assertTrue(kwargs['with_scheduler'])
+
+    def test_system_jobs_must_specify_interval(self):
+        job = MagicMock()
+        job.name = 'TestJob'
+
+        with patch('core.management.commands.rqworker.registry', {'system_jobs': {job: {}}}):
+            with self.assertRaisesMessage(TypeError, 'System job must specify an interval'):
+                call_command('rqworker', stdout=StringIO(), stderr=StringIO())
+
+
+class SyncDataSourceTestCase(TestCase):
+    class FakeDataSource:
+        def __init__(self, name):
+            self.name = name
+            self.pk = name
+            self.sync = MagicMock()
+
+        def __str__(self):
+            return self.name
+
+        def get_status_display(self):
+            return 'completed'
+
+    class FakeQuerySet(list):
+        def values(self, *fields):
+            return [{field: getattr(item, field) for field in fields} for item in self]
+
+    def test_requires_name_or_all(self):
+        with self.assertRaisesMessage(CommandError, 'Must specify at least one data source'):
+            call_command('syncdatasource', stdout=StringIO())
+
+    def test_invalid_name(self):
+        with patch('core.management.commands.syncdatasource.DataSource') as data_source_model:
+            data_source_model.objects.filter.return_value = self.FakeQuerySet()
+            with self.assertRaisesMessage(CommandError, 'Invalid data source names: nonexistent-source'):
+                call_command('syncdatasource', 'nonexistent-source', stdout=StringIO())
+
+        data_source_model.objects.filter.assert_called_once()
+        self.assertEqual(
+            set(data_source_model.objects.filter.call_args.kwargs['name__in']),
+            {'nonexistent-source'},
+        )
+
+    def test_all_syncs_datasource(self):
+        datasource = MagicMock()
+        datasource.__str__.return_value = 'Test Data Source'
+        datasource.get_status_display.return_value = 'completed'
+
+        out = StringIO()
+
+        with patch('core.management.commands.syncdatasource.DataSource') as data_source_model:
+            data_source_model.objects.all.return_value = [datasource]
+            call_command('syncdatasource', sync_all=True, stdout=out)
+
+        data_source_model.objects.all.assert_called_once_with()
+        datasource.sync.assert_called_once_with()
+        self.assertIn('Syncing Test Data Source', out.getvalue())
+        self.assertIn('completed', out.getvalue())
+
+    def test_named_datasource_syncs_matching_datasource(self):
+        datasource = self.FakeDataSource('source-a')
+        datasources = self.FakeQuerySet([datasource])
+        out = StringIO()
+
+        with patch('core.management.commands.syncdatasource.DataSource') as data_source_model:
+            data_source_model.objects.filter.return_value = datasources
+            call_command('syncdatasource', 'source-a', stdout=out)
+
+        data_source_model.objects.filter.assert_called_once()
+        self.assertEqual(
+            set(data_source_model.objects.filter.call_args.kwargs['name__in']),
+            {'source-a'},
+        )
+        datasource.sync.assert_called_once_with()
+        self.assertIn('[1] Syncing source-a', out.getvalue())
+        self.assertIn('completed', out.getvalue())
+        self.assertNotIn('Syncing 1 data sources.', out.getvalue())
+        self.assertNotIn('Finished.', out.getvalue())
+
+    def test_sync_failure_marks_datasource_failed_and_reraises(self):
+        datasource = MagicMock()
+        datasource.__str__.return_value = 'source-a'
+        datasource.pk = 1
+        datasource.sync.side_effect = RuntimeError('boom')
+
+        with patch('core.management.commands.syncdatasource.DataSource') as data_source_model:
+            data_source_model.objects.all.return_value = [datasource]
+
+            with self.assertRaisesMessage(RuntimeError, 'boom'):
+                call_command('syncdatasource', sync_all=True, stdout=StringIO())
+
+        data_source_model.objects.filter.assert_called_once_with(pk=1)
+        data_source_model.objects.filter.return_value.update.assert_called_once_with(
+            status=DataSourceStatusChoices.FAILED,
+        )
+
+    def test_multiple_names_prints_summary_and_syncs_datasources(self):
+        datasource_a = self.FakeDataSource('source-a')
+        datasource_b = self.FakeDataSource('source-b')
+        datasources = self.FakeQuerySet([datasource_a, datasource_b])
+        out = StringIO()
+
+        with patch('core.management.commands.syncdatasource.DataSource') as data_source_model:
+            data_source_model.objects.filter.return_value = datasources
+            call_command('syncdatasource', 'source-a', 'source-b', stdout=out)
+
+        data_source_model.objects.filter.assert_called_once()
+        self.assertEqual(
+            set(data_source_model.objects.filter.call_args.kwargs['name__in']),
+            {'source-a', 'source-b'},
+        )
+        datasource_a.sync.assert_called_once_with()
+        datasource_b.sync.assert_called_once_with()
+        self.assertIn('Syncing 2 data sources.', out.getvalue())
+        self.assertIn('[1] Syncing source-a', out.getvalue())
+        self.assertIn('[2] Syncing source-b', out.getvalue())
+        self.assertIn('Finished.', out.getvalue())

+ 2 - 2
netbox/core/tests/test_models.py

@@ -150,7 +150,7 @@ class DataSourceChangeLoggingTestCase(TestCase):
         self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
         self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
 
 
 
 
-class ObjectTypeTest(TestCase):
+class ObjectTypeTestCase(TestCase):
 
 
     def test_create(self):
     def test_create(self):
         """
         """
@@ -227,7 +227,7 @@ class ObjectTypeTest(TestCase):
         self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
         self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
 
 
 
 
-class JobTest(TestCase):
+class JobTestCase(TestCase):
 
 
     def _make_job(self, user, notifications):
     def _make_job(self, user, notifications):
         """
         """

+ 505 - 0
netbox/core/tests/test_signals.py

@@ -0,0 +1,505 @@
+import uuid
+from types import SimpleNamespace
+from unittest.mock import MagicMock, Mock, patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+from django.core.signals import request_finished
+from django.db import transaction
+from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
+
+from core import signals
+from core.choices import DataSourceStatusChoices, JobStatusChoices, ObjectChangeActionChoices
+from core.models import ConfigRevision, DataSource, ObjectChange, ObjectType
+from core.signals import _signals_received, clear_events, post_sync
+from dcim.models import Site, SiteGroup
+from extras.models import Tag
+from extras.validators import CustomValidator
+from netbox.context import events_queue
+from netbox.context_managers import event_tracking
+from users.models import User
+from utilities.exceptions import AbortRequest
+
+
+def _build_request(user):
+    request = RequestFactory().get('/')
+    request.id = uuid.uuid4()
+    request.user = user
+    return request
+
+
+class UpdateObjectTypesSignalTestCase(TestCase):
+    """
+    Verify core.signals.update_object_types has registered an ObjectType for known
+    models, with the expected public flag and feature set.
+    """
+
+    def test_public_model_object_type_is_registered(self):
+        ot = ObjectType.objects.get(app_label='dcim', model='site')
+        self.assertTrue(ot.public)
+        # Site supports several features — verify a couple representative ones.
+        self.assertIn('custom_fields', ot.features)
+        self.assertIn('tags', ot.features)
+
+    def test_private_model_object_type_is_registered_as_non_public(self):
+        ot = ObjectType.objects.get(app_label='dcim', model='cablepath')
+        self.assertFalse(ot.public)
+
+
+class HandleChangedObjectSignalTestCase(TestCase):
+    """
+    Verify core.signals.handle_changed_object writes an ObjectChange and increments
+    metric counters whenever a tracked object is created or updated within a request.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='alice', password='pw')
+
+    def test_create_records_an_objectchange(self):
+        request = _build_request(self.user)
+        with event_tracking(request):
+            site = Site.objects.create(name='Site 1', slug='site-1')
+
+        oc = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site.pk,
+        )
+        self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertEqual(oc.user, self.user)
+        self.assertEqual(oc.request_id, request.id)
+
+    def test_update_records_an_objectchange(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            site.description = 'updated'
+            site.save()
+
+        ocs = ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site.pk,
+        ).order_by('-pk')
+        self.assertEqual(ocs.first().action, ObjectChangeActionChoices.ACTION_UPDATE)
+
+    def test_no_request_skips_objectchange(self):
+        # Saving outside a request context (no event_tracking) should not record any
+        # ObjectChange entries.
+        Site.objects.create(name='Site 1', slug='site-1')
+        self.assertEqual(ObjectChange.objects.count(), 0)
+
+    def test_m2m_tag_change_records_objectchange_with_postchange_tags(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        tag = Tag.objects.create(name='Important', slug='important')
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            site.tags.add(tag)
+
+        oc = ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(Site),
+            changed_object_id=site.pk,
+        ).first()
+        self.assertEqual(oc.postchange_data['tags'], ['Important'])
+
+
+class HandleDeletedObjectSignalTestCase(TestCase):
+    """
+    Verify core.signals.handle_deleted_object writes a delete-type ObjectChange and
+    respects PROTECTION_RULES.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='alice', password='pw')
+
+    def setUp(self):
+        # Reset the in-memory pre_delete bookkeeping; the signal's de-dup set lives in a
+        # threading.local that is not rolled back between TestCase methods.
+        _signals_received.pre_delete = set()
+
+    def test_delete_records_an_objectchange(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_pk = site.pk
+        site_type = ContentType.objects.get_for_model(Site)
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            site.delete()
+
+        oc = ObjectChange.objects.get(changed_object_type=site_type, changed_object_id=site_pk)
+        self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertIsNone(oc.postchange_data)
+
+    @override_settings(PROTECTION_RULES={'dcim.site': [CustomValidator({'name': {'neq': 'protected'}})]})
+    def test_protection_rule_violation_aborts_deletion(self):
+        site = Site.objects.create(name='protected', slug='protected')
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            # The signal raises AbortRequest from a pre_delete handler, which can poison
+            # the surrounding transaction; isolate the delete in its own atomic block.
+            with self.assertRaises((AbortRequest, ValidationError)):
+                with transaction.atomic():
+                    site.delete()
+
+        self.assertTrue(Site.objects.filter(pk=site.pk).exists())
+
+    def test_delete_records_single_objectchange(self):
+        # A delete should record exactly one ObjectChange for the deleted object.
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_pk = site.pk
+        site_type = ContentType.objects.get_for_model(Site)
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            site.delete()
+
+        ocs = ObjectChange.objects.filter(changed_object_type=site_type, changed_object_id=site_pk)
+        self.assertEqual(ocs.count(), 1)
+
+    def test_duplicate_pre_delete_for_same_instance_is_ignored(self):
+        # Exercise the dedup short-circuit by invoking the handler twice for the
+        # same instance, mirroring what happens when a parent and its child are
+        # deleted simultaneously and the same pre_delete fires more than once.
+        # Only the first invocation should record an ObjectChange.
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_pk = site.pk
+        site_type = ContentType.objects.get_for_model(Site)
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            signals.handle_deleted_object(sender=Site, instance=site)
+            signals.handle_deleted_object(sender=Site, instance=site)
+
+        ocs = ObjectChange.objects.filter(
+            changed_object_type=site_type,
+            changed_object_id=site_pk,
+            action=ObjectChangeActionChoices.ACTION_DELETE,
+        )
+        self.assertEqual(ocs.count(), 1)
+
+    def test_delete_records_change_for_objects_with_nulled_fk(self):
+        # When a parent is deleted, related objects with on_delete=SET_NULL have
+        # their FK cleared by the signal *and* receive a change-log entry via
+        # snapshot()+save(). Without the signal, Django's SET_NULL would clear
+        # the FK silently with no ObjectChange.
+        group = SiteGroup.objects.create(name='Group', slug='group')
+        group_pk = group.pk
+        site = Site.objects.create(name='Site', slug='site', group=group)
+        site_type = ContentType.objects.get_for_model(Site)
+        request = _build_request(self.user)
+
+        with event_tracking(request):
+            group.delete()
+
+        site.refresh_from_db()
+        self.assertIsNone(site.group)
+        oc = ObjectChange.objects.get(
+            changed_object_type=site_type,
+            changed_object_id=site.pk,
+            action=ObjectChangeActionChoices.ACTION_UPDATE,
+        )
+        self.assertEqual(oc.prechange_data['group'], group_pk)
+        self.assertIsNone(oc.postchange_data['group'])
+
+
+class ClearSignalHistorySignalTestCase(TestCase):
+    """
+    Verify core.signals.clear_signal_history resets the pre_delete bookkeeping at the
+    end of every request.
+    """
+
+    def test_request_finished_clears_history(self):
+        _signals_received.pre_delete = {('a', 1), ('b', 2)}
+
+        request_finished.send(sender=self.__class__)
+
+        self.assertEqual(_signals_received.pre_delete, set())
+
+
+class ClearEventsQueueSignalTestCase(TestCase):
+    """
+    Verify core.signals.clear_events_queue empties the in-flight events queue when the
+    clear_events signal fires (e.g. during a rolled-back bulk transaction).
+    """
+
+    def test_clear_events_signal_empties_the_queue(self):
+        events_queue.set({'event-1': object(), 'event-2': object()})
+
+        clear_events.send(sender='test-suite')
+
+        self.assertEqual(events_queue.get(), {})
+
+
+class EnqueueSyncJobSignalTestCase(TestCase):
+    """
+    Verify core.signals.enqueue_sync_job schedules a recurring sync job when a
+    DataSource is saved with a sync_interval, and removes any existing schedule
+    otherwise.
+    """
+
+    def test_saving_datasource_with_interval_enqueues_sync_job(self):
+        with patch('core.jobs.SyncDataSourceJob') as sync_job:
+            DataSource.objects.create(
+                name='DS 1',
+                type='local',
+                source_url='/tmp/ds1',
+                enabled=True,
+                sync_interval=60,
+            )
+
+        sync_job.enqueue_once.assert_called_once()
+        _, kwargs = sync_job.enqueue_once.call_args
+        self.assertEqual(kwargs.get('interval'), 60)
+
+    def test_disabled_datasource_clears_scheduled_jobs(self):
+        class ScheduledJobQueryset:
+            def __init__(self, jobs):
+                self.jobs = jobs
+                self.defer = Mock(return_value=self)
+                self.filter = Mock(return_value=self)
+
+            def __iter__(self):
+                return iter(self.jobs)
+
+        with patch('core.jobs.SyncDataSourceJob') as sync_job:
+            ds = DataSource.objects.create(
+                name='DS 1',
+                type='local',
+                source_url='/tmp/ds1',
+                enabled=True,
+                sync_interval=60,
+            )
+            sync_job.reset_mock()
+
+            scheduled_job = Mock()
+            scheduled_jobs = ScheduledJobQueryset([scheduled_job])
+            sync_job.get_jobs.return_value = scheduled_jobs
+            ds.enabled = False
+            ds.sync_interval = None
+            ds.save()
+
+        sync_job.get_jobs.assert_called_once_with(ds)
+        scheduled_jobs.defer.assert_called_once_with('data')
+        scheduled_jobs.filter.assert_called_once_with(interval__isnull=False, status=JobStatusChoices.STATUS_SCHEDULED)
+        scheduled_job.delete.assert_called_once_with()
+
+    def test_creating_disabled_datasource_does_not_enqueue(self):
+        with patch('core.jobs.SyncDataSourceJob') as sync_job:
+            DataSource.objects.create(
+                name='DS 1',
+                type='local',
+                source_url='/tmp/ds1',
+                enabled=False,
+                sync_interval=None,
+            )
+
+        sync_job.enqueue_once.assert_not_called()
+        sync_job.get_jobs.assert_not_called()
+
+
+class AutoSyncSignalTestCase(TestCase):
+    """
+    Verify core.signals.auto_sync re-syncs every AutoSyncRecord linked to the
+    DataSource when post_sync fires.
+    """
+
+    def test_post_sync_resyncs_dependent_records(self):
+        ds = DataSource.objects.create(
+            name='DS 1',
+            type='local',
+            source_url='/tmp/ds1',
+            status=DataSourceStatusChoices.COMPLETED,
+        )
+        record_a = SimpleNamespace(object=SimpleNamespace(synced=False))
+        record_a.object.sync = lambda save: setattr(record_a.object, 'synced', save)
+        record_b = SimpleNamespace(object=SimpleNamespace(synced=False))
+        record_b.object.sync = lambda save: setattr(record_b.object, 'synced', save)
+
+        with patch('core.models.AutoSyncRecord') as autosync_model:
+            autosync_model.objects.filter.return_value.prefetch_related.return_value = [
+                record_a,
+                record_b,
+            ]
+            post_sync.send(sender=ds.__class__, instance=ds)
+
+        self.assertTrue(record_a.object.synced)
+        self.assertTrue(record_b.object.synced)
+
+
+class UpdateConfigSignalTestCase(TestCase):
+    """
+    Verify core.signals.update_config invokes activate() on a newly-saved
+    ConfigRevision.
+    """
+
+    def test_saving_config_revision_activates_it(self):
+        with patch.object(ConfigRevision, 'activate') as activate:
+            ConfigRevision.objects.create(data={'foo': 1}, comment='test')
+
+        activate.assert_called_once()
+
+
+class HandleChangedObjectDirectHandlerTestCase(SimpleTestCase):
+    """
+    Direct-call tests for handle_changed_object branches that are not naturally
+    reachable through ORM operations (a real .save() always produces changes, and
+    Django collapses every m2m_changed action into a single dispatch the handler
+    cannot fully simulate via real m2m operations).
+    """
+
+    def _instance(self):
+        objectchange = MagicMock()
+        objectchange.has_changes = True
+        objectchange.postchange_data = {'name': 'Device 1'}
+        instance = SimpleNamespace(
+            pk=123,
+            _meta=SimpleNamespace(model_name='device'),
+            refresh_from_db=MagicMock(),
+        )
+        instance.to_objectchange = MagicMock(return_value=objectchange)
+        return instance, objectchange
+
+    def test_unhandled_m2m_action_returns_without_recording(self):
+        request = SimpleNamespace(id='request-id', user='alice')
+        instance, _ = self._instance()
+        current_request = MagicMock()
+        current_request.get.return_value = request
+
+        with patch.object(signals, 'current_request', current_request):
+            signals.handle_changed_object(
+                sender=None,
+                instance=instance,
+                action='pre_add',
+                pk_set={1},
+            )
+
+        instance.to_objectchange.assert_not_called()
+
+    def test_objectchange_without_changes_is_not_saved(self):
+        request = SimpleNamespace(id='request-id', user='alice')
+        instance, objectchange = self._instance()
+        objectchange.has_changes = False
+        current_request = MagicMock()
+        current_request.get.return_value = request
+        events_queue_mock = MagicMock()
+        events_queue_mock.get.return_value = {}
+        update_metric = MagicMock()
+
+        with (
+            patch.object(signals, 'current_request', current_request),
+            patch.object(signals, 'events_queue', events_queue_mock),
+            patch.object(signals, 'enqueue_event') as enqueue_event,
+            patch.object(signals.model_updates, 'labels', return_value=update_metric),
+        ):
+            signals.handle_changed_object(sender=None, instance=instance, created=False)
+
+        instance.to_objectchange.assert_called_once_with(ObjectChangeActionChoices.ACTION_UPDATE)
+        # has_changes is False, so the ObjectChange should never be saved …
+        objectchange.save.assert_not_called()
+        # … but metric counters and event enqueueing still run.
+        update_metric.inc.assert_called_once_with()
+        enqueue_event.assert_called_once()
+
+    def test_m2m_change_updates_existing_objectchange_in_same_request(self):
+        request = SimpleNamespace(id='request-id', user='alice')
+        instance, objectchange = self._instance()
+        previous_change = MagicMock()
+        current_request = MagicMock()
+        current_request.get.return_value = request
+        events_queue_mock = MagicMock()
+        events_queue_mock.get.return_value = {}
+        objectchange_model = MagicMock()
+        objectchange_model.objects.filter.return_value.first.return_value = previous_change
+        content_type_model = MagicMock()
+        content_type_model.objects.get_for_model.return_value = object()
+
+        with (
+            patch.object(signals, 'current_request', current_request),
+            patch.object(signals, 'events_queue', events_queue_mock),
+            patch.object(signals, 'ObjectChange', objectchange_model),
+            patch.object(signals, 'ContentType', content_type_model),
+            patch.object(signals, 'enqueue_event'),
+            patch.object(signals.model_updates, 'labels', return_value=MagicMock()),
+        ):
+            signals.handle_changed_object(
+                sender=None,
+                instance=instance,
+                action='post_add',
+                pk_set={1},
+            )
+
+        # The handler should update the existing ObjectChange instead of creating a new one.
+        self.assertEqual(previous_change.postchange_data, objectchange.postchange_data)
+        previous_change.save.assert_called_once_with()
+        objectchange.save.assert_not_called()
+        instance.refresh_from_db.assert_called_once_with()
+
+
+class HandleDeletedObjectDirectHandlerTestCase(SimpleTestCase):
+    """
+    Direct-call tests for handle_deleted_object branches that are hard to construct
+    via real model operations (notably _netbox_private skip, which requires a
+    private-model instance with reverse relations the test can introspect).
+    """
+
+    def setUp(self):
+        _signals_received.pre_delete = set()
+
+    def test_private_model_skips_reverse_relation_processing(self):
+        # Build a relation the handler would normally process — a ManyToOneRel-typed
+        # instance pointing at a ChangeLoggingMixin subclass. The handler narrows by
+        # exact type (`type(relation) is ManyToOneRel`), so do not use
+        # MagicMock(spec=ManyToOneRel): it would be skipped before reaching the
+        # _netbox_private branch. Patching ManyToOneRel to this fake class keeps the
+        # exact-type check meaningful, so related_model.objects.filter() would be
+        # called if the private-model skip failed.
+        class FakeManyToOneRel:
+            pass
+
+        class FakeChangeLoggingMixin:
+            pass
+
+        class FakeRelatedModel(FakeChangeLoggingMixin):
+            pass
+
+        FakeRelatedModel.objects = MagicMock()
+
+        fake_relation = FakeManyToOneRel()
+        fake_relation.related_model = FakeRelatedModel
+        fake_relation.remote_field = SimpleNamespace(name='parent')
+        fake_relation.null = True
+        fake_relation.on_delete = object()
+
+        sender = SimpleNamespace(_meta=SimpleNamespace(app_label='dcim', model_name='cablepath'))
+        request = SimpleNamespace(id='request-id', user='alice')
+        instance = SimpleNamespace(
+            pk=1,
+            _meta=SimpleNamespace(model_name='cablepath', related_objects=[fake_relation]),
+            _netbox_private=True,
+        )
+        # Private models typically don't have to_objectchange, so skip change-log too.
+        config = SimpleNamespace(PROTECTION_RULES={})
+        current_request = MagicMock()
+        current_request.get.return_value = request
+        events_queue_mock = MagicMock()
+        events_queue_mock.get.return_value = {}
+
+        with (
+            patch.object(signals, 'ManyToOneRel', FakeManyToOneRel),
+            patch.object(signals, 'get_config', return_value=config),
+            patch.object(signals, 'get_config_value_ci', return_value=[]),
+            patch.object(signals, 'run_validators'),
+            patch.object(signals, 'current_request', current_request),
+            patch.object(signals, 'events_queue', events_queue_mock),
+            patch.object(signals, 'ContentType') as content_type_model,
+            patch.object(signals, 'ChangeLoggingMixin', FakeChangeLoggingMixin),
+            patch.object(signals, 'enqueue_event'),
+            patch.object(signals.model_deletes, 'labels', return_value=MagicMock()),
+        ):
+            content_type_model.objects.get_for_model.return_value = object()
+            signals.handle_deleted_object(sender=sender, instance=instance)
+
+        FakeRelatedModel.objects.filter.assert_not_called()

+ 5 - 5
netbox/core/tests/test_tables.py

@@ -3,24 +3,24 @@ from core.tables import *
 from utilities.testing import TableTestCases
 from utilities.testing import TableTestCases
 
 
 
 
-class DataSourceTableTest(TableTestCases.StandardTableTestCase):
+class DataSourceTableTestCase(TableTestCases.StandardTableTestCase):
     table = DataSourceTable
     table = DataSourceTable
 
 
 
 
-class DataFileTableTest(TableTestCases.StandardTableTestCase):
+class DataFileTableTestCase(TableTestCases.StandardTableTestCase):
     table = DataFileTable
     table = DataFileTable
 
 
 
 
-class JobTableTest(TableTestCases.StandardTableTestCase):
+class JobTableTestCase(TableTestCases.StandardTableTestCase):
     table = JobTable
     table = JobTable
 
 
 
 
-class ObjectChangeTableTest(TableTestCases.StandardTableTestCase):
+class ObjectChangeTableTestCase(TableTestCases.StandardTableTestCase):
     table = ObjectChangeTable
     table = ObjectChangeTable
     queryset_sources = [
     queryset_sources = [
         ('ObjectChangeListView', ObjectChange.objects.all()),
         ('ObjectChangeListView', ObjectChange.objects.all()),
     ]
     ]
 
 
 
 
-class ConfigRevisionTableTest(TableTestCases.StandardTableTestCase):
+class ConfigRevisionTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConfigRevisionTable
     table = ConfigRevisionTable

+ 5 - 0
netbox/dcim/api/serializers_/rackunits.py

@@ -26,7 +26,12 @@ class RackUnitSerializer(serializers.Serializer):
     device = DeviceSerializer(nested=True, read_only=True)
     device = DeviceSerializer(nested=True, read_only=True)
     occupied = serializers.BooleanField(read_only=True)
     occupied = serializers.BooleanField(read_only=True)
     display = serializers.SerializerMethodField(read_only=True)
     display = serializers.SerializerMethodField(read_only=True)
+    description = serializers.SerializerMethodField(read_only=True)
 
 
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
     def get_display(self, obj):
     def get_display(self, obj):
         return obj['name']
         return obj['name']
+
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_description(self, obj):
+        return f'{obj["device"]}' if obj['device'] else None

+ 65 - 0
netbox/dcim/filtersets.py

@@ -2806,12 +2806,77 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
 
 
 @register_filterset
 @register_filterset
 class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
 class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
+    cable_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Cable.objects.all(),
+        distinct=False,
+        label=_('Cable (ID)'),
+    )
     termination_type = MultiValueContentTypeFilter()
     termination_type = MultiValueContentTypeFilter()
 
 
+    # Termination object filters
+    consoleport_id = MultiValueNumberFilter(
+        method='filter_by_consoleport'
+    )
+    consoleserverport_id = MultiValueNumberFilter(
+        method='filter_by_consoleserverport'
+    )
+    powerport_id = MultiValueNumberFilter(
+        method='filter_by_powerport'
+    )
+    poweroutlet_id = MultiValueNumberFilter(
+        method='filter_by_poweroutlet'
+    )
+    interface_id = MultiValueNumberFilter(
+        method='filter_by_interface'
+    )
+    frontport_id = MultiValueNumberFilter(
+        method='filter_by_frontport'
+    )
+    rearport_id = MultiValueNumberFilter(
+        method='filter_by_rearport'
+    )
+    powerfeed_id = MultiValueNumberFilter(
+        method='filter_by_powerfeed'
+    )
+    circuittermination_id = MultiValueNumberFilter(
+        method='filter_by_circuittermination'
+    )
+
     class Meta:
     class Meta:
         model = CableTermination
         model = CableTermination
         fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
         fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
 
 
+    def filter_by_termination_object(self, queryset, model, value):
+        content_type = ContentType.objects.get_for_model(model)
+        return queryset.filter(termination_type=content_type, termination_id__in=value)
+
+    def filter_by_consoleport(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, ConsolePort, value)
+
+    def filter_by_consoleserverport(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, ConsoleServerPort, value)
+
+    def filter_by_powerport(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, PowerPort, value)
+
+    def filter_by_poweroutlet(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, PowerOutlet, value)
+
+    def filter_by_interface(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, Interface, value)
+
+    def filter_by_frontport(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, FrontPort, value)
+
+    def filter_by_rearport(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, RearPort, value)
+
+    def filter_by_powerfeed(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, PowerFeed, value)
+
+    def filter_by_circuittermination(self, queryset, name, value):
+        return self.filter_by_termination_object(queryset, CircuitTermination, value)
+
 
 
 @register_filterset
 @register_filterset
 class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
 class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):

+ 2 - 1
netbox/dcim/forms/model_forms.py

@@ -690,7 +690,8 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
     )
     )
     local_context_data = JSONField(
     local_context_data = JSONField(
         required=False,
         required=False,
-        label=''
+        label='',
+        widget=forms.Textarea(attrs={'aria-label': _('Local config context data')})
     )
     )
     virtual_chassis = DynamicModelChoiceField(
     virtual_chassis = DynamicModelChoiceField(
         label=_('Virtual chassis'),
         label=_('Virtual chassis'),

+ 55 - 18
netbox/dcim/graphql/types.py

@@ -4,6 +4,7 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from django.db.models import Func, IntegerField
 from django.db.models import Func, IntegerField
 
 
+from circuits.models import CircuitTermination
 from core.graphql.mixins import ChangelogMixin
 from core.graphql.mixins import ChangelogMixin
 from dcim import models
 from dcim import models
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
 from extras.graphql.mixins import ConfigContextMixin, ContactsMixin, ImageAttachmentsMixin
@@ -17,6 +18,8 @@ from netbox.graphql.types import (
     PrimaryObjectType,
     PrimaryObjectType,
 )
 )
 from users.graphql.mixins import OwnerMixin
 from users.graphql.mixins import OwnerMixin
+from utilities.querysets import RestrictedPrefetch
+from virtualization.models import Cluster
 
 
 from .filters import *
 from .filters import *
 from .mixins import CabledObjectMixin, PathEndpointMixin
 from .mixins import CabledObjectMixin, PathEndpointMixin
@@ -288,11 +291,11 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, Prima
     inventoryitems: list[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
     inventoryitems: list[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
     vdcs: list[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
     vdcs: list[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='vc_master_for')
     def vc_master_for(self) -> Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None:
     def vc_master_for(self) -> Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.vc_master_for if hasattr(self, 'vc_master_for') else None
         return self.vc_master_for if hasattr(self, 'vc_master_for') else None
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent_bay')
     def parent_bay(self) -> Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent_bay(self) -> Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent_bay if hasattr(self, 'parent_bay') else None
         return self.parent_bay if hasattr(self, 'parent_bay') else None
 
 
@@ -327,7 +330,7 @@ class InventoryItemTemplateType(ComponentTemplateType):
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     role: Annotated['InventoryItemRoleType', strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated['ManufacturerType', strawberry.lazy('dcim.graphql.types')]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated['InventoryItemTemplateType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -430,7 +433,7 @@ class FrontPortTemplateType(ModularComponentTemplateType):
 class MACAddressType(PrimaryObjectType):
 class MACAddressType(PrimaryObjectType):
     mac_address: str
     mac_address: str
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='assigned_object')
     def assigned_object(self) -> Annotated[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -494,7 +497,7 @@ class InventoryItemType(ComponentType):
 
 
     child_items: list[Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')]]
     child_items: list[Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated['InventoryItemType', strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -541,11 +544,20 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Nested
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'cluster_set', info.context.request.user, 'view', queryset=Cluster.objects.all()
+        ),
+    )
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
         return self.cluster_set.all()
         return self.cluster_set.all()
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'circuit_terminations', info.context.request.user, 'view',
+            queryset=CircuitTermination.objects.all()
+        ),
+    )
     def circuit_terminations(self) -> list[
     def circuit_terminations(self) -> list[
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
     ]:
     ]:
@@ -599,7 +611,7 @@ class ModuleBayType(ModularComponentType):
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
@@ -864,15 +876,24 @@ class RegionType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'cluster_set', info.context.request.user, 'view', queryset=Cluster.objects.all()
+        ),
+    )
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
         return self.cluster_set.all()
         return self.cluster_set.all()
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'circuit_terminations', info.context.request.user, 'view',
+            queryset=CircuitTermination.objects.all()
+        ),
+    )
     def circuit_terminations(self) -> list[
     def circuit_terminations(self) -> list[
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
     ]:
     ]:
@@ -899,15 +920,22 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: list[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     locations: list[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     locations: list[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     asns: list[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
     asns: list[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
-    circuit_terminations: list[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
-    clusters: list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
     vlans: list[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
     vlans: list[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'cluster_set', info.context.request.user, 'view', queryset=Cluster.objects.all()
+        ),
+    )
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
         return self.cluster_set.all()
         return self.cluster_set.all()
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'circuit_terminations', info.context.request.user, 'view',
+            queryset=CircuitTermination.objects.all()
+        ),
+    )
     def circuit_terminations(self) -> list[
     def circuit_terminations(self) -> list[
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
     ]:
     ]:
@@ -925,15 +953,24 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, NestedGroupObjectType):
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     sites: list[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
     children: list[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'cluster_set', info.context.request.user, 'view', queryset=Cluster.objects.all()
+        ),
+    )
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
     def clusters(self) -> list[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
         return self.cluster_set.all()
         return self.cluster_set.all()
 
 
-    @strawberry_django.field
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'circuit_terminations', info.context.request.user, 'view',
+            queryset=CircuitTermination.objects.all()
+        ),
+    )
     def circuit_terminations(self) -> list[
     def circuit_terminations(self) -> list[
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
         Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]
     ]:
     ]:

+ 15 - 0
netbox/dcim/migrations/0234_cablepath_nodes_index.py

@@ -0,0 +1,15 @@
+import django.contrib.postgres.indexes
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0233_device_render_config_permission'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='cablepath',
+            index=django.contrib.postgres.indexes.GinIndex(fields=['_nodes'], name='dcim_cablep__nodes_b23b96_gin'),
+        ),
+    ]

+ 32 - 0
netbox/dcim/models/cables.py

@@ -1,10 +1,12 @@
 import itertools
 import itertools
 import logging
 import logging
+import threading
 from collections import Counter
 from collections import Counter
 
 
 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
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.fields import ArrayField
+from django.contrib.postgres.indexes import GinIndex
 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
 from django.db import models
 from django.db import models
@@ -75,6 +77,11 @@ class Cable(PrimaryModel):
     """
     """
     A physical connection between two endpoints.
     A physical connection between two endpoints.
     """
     """
+    # Per-thread tracking of Cable PKs currently in delete(); referenced by
+    # dcim.signals.nullify_connected_endpoints to skip per-CableTermination
+    # cable path retracing during cascade (retrace_cable_paths handles it once).
+    _deletion_tracking = threading.local()
+
     type = models.CharField(
     type = models.CharField(
         verbose_name=_('type'),
         verbose_name=_('type'),
         max_length=50,
         max_length=50,
@@ -342,6 +349,26 @@ class Cable(PrimaryModel):
         except UnsupportedCablePath as e:
         except UnsupportedCablePath as e:
             raise AbortRequest(e)
             raise AbortRequest(e)
 
 
+    def delete(self, *args, **kwargs):
+        # Track this Cable as being deleted so the post_delete signal handler
+        # for cascaded CableTerminations can skip redundant path retracing;
+        # retrace_cable_paths() will retrace each affected path once after the
+        # Cable itself is deleted. Cache the PK locally because super().delete()
+        # clears self.pk before the finally block runs. The tracking set lives
+        # on a threading.local() to isolate concurrent deletions across threads.
+        if not hasattr(Cable._deletion_tracking, 'pks'):
+            Cable._deletion_tracking.pks = set()
+        pk = self.pk
+        Cable._deletion_tracking.pks.add(pk)
+        try:
+            return super().delete(*args, **kwargs)
+        finally:
+            Cable._deletion_tracking.pks.discard(pk)
+
+    @classmethod
+    def _is_being_deleted(cls, pk):
+        return pk in getattr(cls._deletion_tracking, 'pks', ())
+
     def clone(self):
     def clone(self):
         """
         """
         Return attributes suitable for cloning this cable.
         Return attributes suitable for cloning this cable.
@@ -730,6 +757,11 @@ class CablePath(models.Model):
     _netbox_private = True
     _netbox_private = True
 
 
     class Meta:
     class Meta:
+        indexes = (
+            # GIN index supports @> operator used by `_nodes__contains` lookups,
+            # which fire on every cable/termination delete and path retrace.
+            GinIndex(fields=('_nodes',)),
+        )
         verbose_name = _('cable path')
         verbose_name = _('cable path')
         verbose_name_plural = _('cable paths')
         verbose_name_plural = _('cable paths')
 
 

+ 60 - 1
netbox/dcim/models/device_components.py

@@ -1313,6 +1313,29 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 # Bays
 # Bays
 #
 #
 
 
+class ModuleBayManager(TreeManager):
+    """
+    Order ModuleBays by the natural-sort name of each tree's root, then by MPTT
+    left value within the tree. Combined with the root-insert bypass in
+    ModuleBay.save(), this lets us keep MPTTMeta.order_insertion_by for cheap
+    intra-tree sibling placement while skipping the global tree_id renumbering
+    it would otherwise perform on every root insert.
+    """
+    def get_queryset(self, *args, **kwargs):
+        # Use the raw manager to avoid recursing through this get_queryset() when
+        # building the annotation subquery.
+        root_name = self.model._objects_raw.filter(
+            tree_id=models.OuterRef('tree_id'),
+            level=0,
+        ).values('name')[:1]
+        return super().get_queryset(*args, **kwargs).annotate(
+            _root_name=models.Subquery(
+                root_name,
+                output_field=models.CharField(db_collation='natural_sort'),
+            )
+        ).order_by('_root_name', 'lft')
+
+
 class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
 class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
     """
     """
     An empty space within a Device which can house a child device
     An empty space within a Device which can house a child device
@@ -1337,7 +1360,10 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         default=True,
         default=True,
     )
     )
 
 
-    objects = TreeManager()
+    objects = ModuleBayManager()
+    # Plain TreeManager used by ModuleBayManager to build the _root_name subquery
+    # without recursing through our annotated get_queryset().
+    _objects_raw = TreeManager()
 
 
     clone_fields = ('device', 'enabled')
     clone_fields = ('device', 'enabled')
 
 
@@ -1355,6 +1381,9 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
         verbose_name_plural = _('module bays')
         verbose_name_plural = _('module bays')
 
 
     class MPTTMeta:
     class MPTTMeta:
+        # Used for placing siblings within a single tree at insert time. Costs
+        # are bounded to that tree's rows. Cross-tree (root) insertion is
+        # handled in save() to avoid the O(N) tree_id shift this would trigger.
         order_insertion_by = ('name',)
         order_insertion_by = ('name',)
 
 
     def clean(self):
     def clean(self):
@@ -1376,6 +1405,36 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
             self.parent = self.module.module_bay
             self.parent = self.module.module_bay
         else:
         else:
             self.parent = None
             self.parent = None
+        opts = self._mptt_meta
+        # For new root nodes, allocate the next tree_id and pre-set MPTT fields
+        # so super().save() skips MPTT's order_insertion_by-driven insertion
+        # path. That path would otherwise UPDATE every later tree_id on each
+        # root insert (NB-2800). Children still go through MPTT, which keeps
+        # siblings in name order via the same order_insertion_by setting.
+        if self._state.adding and self.parent_id is None and not self.lft and not self.rght:
+            max_tree_id = ModuleBay._objects_raw.aggregate(
+                models.Max('tree_id')
+            )['tree_id__max'] or 0
+            self.tree_id = max_tree_id + 1
+            self.lft = 1
+            self.rght = 2
+            self.level = 0
+        elif (
+            not self._state.adding
+            and self.parent_id is None
+            and self._mptt_cached_fields.get(opts.parent_attr) is None
+        ):
+            # Existing root being updated. Spoof the cached order_insertion_by
+            # values so MPTT's same_order check passes and it skips its reorder
+            # path, which would UPDATE every later tree_id on each root rename.
+            # ModuleBayManager._root_name recovers display order at query time,
+            # so the tree_id reshuffling would be wasted work. Child renames
+            # and root<->child transitions intentionally fall through to MPTT.
+            for field_name in opts.order_insertion_by:
+                field_name = field_name.lstrip('-')
+                self._mptt_cached_fields[field_name] = opts.get_raw_field_value(
+                    self, field_name
+                )
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     @property
     @property

+ 6 - 0
netbox/dcim/signals.py

@@ -185,6 +185,12 @@ def nullify_connected_endpoints(instance, **kwargs):
     model = instance.termination_type.model_class()
     model = instance.termination_type.model_class()
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
 
 
+    # If the parent Cable is being deleted in this same operation, skip the
+    # per-termination retrace; retrace_cable_paths() will retrace each affected
+    # path once after the Cable is deleted.
+    if Cable._is_being_deleted(instance.cable_id):
+        return
+
     for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
     for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
         # Remove the deleted CableTermination if it's one of the path's originating nodes
         # Remove the deleted CableTermination if it's one of the path's originating nodes
         if instance.termination in cablepath.origins:
         if instance.termination in cablepath.origins:

+ 78 - 50
netbox/dcim/tests/test_api.py

@@ -1,7 +1,7 @@
 import json
 import json
 
 
 from django.conf import settings
 from django.conf import settings
-from django.test import override_settings, tag
+from django.test import tag
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from rest_framework import status
 from rest_framework import status
@@ -29,7 +29,7 @@ from wireless.choices import WirelessChannelChoices
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
 
 
-class AppTest(APITestCase):
+class AppTestCase(APITestCase):
 
 
     def test_root(self):
     def test_root(self):
 
 
@@ -76,7 +76,7 @@ class Mixins:
             self.assertEqual(segment1[2][0]['name'], peer_obj.name)
             self.assertEqual(segment1[2][0]['name'], peer_obj.name)
 
 
 
 
-class RegionTest(APIViewTestCases.APIViewTestCase):
+class RegionTestCase(APIViewTestCases.APIViewTestCase):
     model = Region
     model = Region
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     create_data = [
     create_data = [
@@ -107,7 +107,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
         Region.objects.create(name='Region 3', slug='region-3')
         Region.objects.create(name='Region 3', slug='region-3')
 
 
 
 
-class SiteGroupTest(APIViewTestCases.APIViewTestCase):
+class SiteGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = SiteGroup
     model = SiteGroup
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     create_data = [
     create_data = [
@@ -140,7 +140,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
         SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
         SiteGroup.objects.create(name='Site Group 3', slug='site-group-3', comments='Hi!')
 
 
 
 
-class SiteTest(APIViewTestCases.APIViewTestCase):
+class SiteTestCase(APIViewTestCases.APIViewTestCase):
     model = Site
     model = Site
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -420,7 +420,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
 
 
-class LocationTest(APIViewTestCases.APIViewTestCase):
+class LocationTestCase(APIViewTestCases.APIViewTestCase):
     model = Location
     model = Location
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -504,7 +504,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class RackGroupTest(APIViewTestCases.APIViewTestCase):
+class RackGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = RackGroup
     model = RackGroup
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     create_data = [
     create_data = [
@@ -536,7 +536,7 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase):
         RackGroup.objects.bulk_create(rack_groups)
         RackGroup.objects.bulk_create(rack_groups)
 
 
 
 
-class RackRoleTest(APIViewTestCases.APIViewTestCase):
+class RackRoleTestCase(APIViewTestCases.APIViewTestCase):
     model = RackRole
     model = RackRole
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     create_data = [
     create_data = [
@@ -571,7 +571,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
         RackRole.objects.bulk_create(rack_roles)
         RackRole.objects.bulk_create(rack_roles)
 
 
 
 
-class RackTypeTest(APIViewTestCases.APIViewTestCase):
+class RackTypeTestCase(APIViewTestCases.APIViewTestCase):
     model = RackType
     model = RackType
     brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -631,7 +631,7 @@ class RackTypeTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class RackTest(APIViewTestCases.APIViewTestCase):
+class RackTestCase(APIViewTestCases.APIViewTestCase):
     model = Rack
     model = Rack
     brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -716,6 +716,34 @@ class RackTest(APIViewTestCases.APIViewTestCase):
         response = self.client.get(f'{url}?q=U10', **self.header)
         response = self.client.get(f'{url}?q=U10', **self.header)
         self.assertEqual(response.data['count'], 2)
         self.assertEqual(response.data['count'], 2)
 
 
+    def test_get_rack_elevation_description_is_occupying_device_name(self):
+        """
+        Verify occupied rack units include the occupying device in their description.
+        """
+        rack = Rack.objects.first()
+        self.add_permissions('dcim.view_rack', 'dcim.view_device')
+        url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
+
+        device = create_test_device(
+            name='Device A',
+            site=rack.site,
+            rack=rack,
+            position=40,
+            face=DeviceFaceChoices.FACE_FRONT,
+        )
+
+        # Retrieve all units
+        response = self.client.get(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        occupied_unit = next(unit for unit in response.data['results'] if unit['name'] == 'U40')
+        self.assertEqual(occupied_unit['device']['id'], device.pk)
+        self.assertEqual(occupied_unit['description'], f'{device}')
+
+        unoccupied_unit = next(unit for unit in response.data['results'] if unit['name'] == 'U39')
+        self.assertEqual(unoccupied_unit['device'], None)
+        self.assertEqual(unoccupied_unit['description'], None)
+
     def test_get_rack_elevation_svg(self):
     def test_get_rack_elevation_svg(self):
         """
         """
         GET a single rack elevation in SVG format.
         GET a single rack elevation in SVG format.
@@ -729,7 +757,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
         self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
 
 
 
 
-class RackReservationTest(APIViewTestCases.APIViewTestCase):
+class RackReservationTestCase(APIViewTestCases.APIViewTestCase):
     model = RackReservation
     model = RackReservation
     brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
     brief_fields = ['description', 'display', 'id', 'status', 'units', 'url', 'user']
     bulk_update_data = {
     bulk_update_data = {
@@ -804,7 +832,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
             self.assertEqual(result['unit_count'], len(result['units']))
             self.assertEqual(result['unit_count'], len(result['units']))
 
 
 
 
-class ManufacturerTest(APIViewTestCases.APIViewTestCase):
+class ManufacturerTestCase(APIViewTestCases.APIViewTestCase):
     model = Manufacturer
     model = Manufacturer
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
@@ -836,7 +864,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
         Manufacturer.objects.bulk_create(manufacturers)
         Manufacturer.objects.bulk_create(manufacturers)
 
 
 
 
-class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
+class DeviceTypeTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceType
     model = DeviceType
     brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
     brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -882,7 +910,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
+class ModuleTypeTestCase(APIViewTestCases.APIViewTestCase):
     model = ModuleType
     model = ModuleType
     brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
     brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -922,7 +950,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase):
+class ModuleTypeProfileTestCase(APIViewTestCases.APIViewTestCase):
     model = ModuleTypeProfile
     model = ModuleTypeProfile
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     SCHEMAS = [
     SCHEMAS = [
@@ -986,7 +1014,7 @@ class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase):
         ModuleTypeProfile.objects.bulk_create(module_type_profiles)
         ModuleTypeProfile.objects.bulk_create(module_type_profiles)
 
 
 
 
-class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
+class ConsolePortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1030,7 +1058,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
+class ConsoleServerPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1074,7 +1102,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
+class PowerPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = PowerPortTemplate
     model = PowerPortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1118,7 +1146,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
+class PowerOutletTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = PowerOutletTemplate
     model = PowerOutletTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1176,7 +1204,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
+class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = InterfaceTemplate
     model = InterfaceTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1224,7 +1252,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
+class FrontPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = FrontPortTemplate
     model = FrontPortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1341,7 +1369,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
         )
         )
 
 
 
 
-class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
+class RearPortTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1457,7 +1485,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
         )
         )
 
 
 
 
-class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
+class ModuleBayTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
     brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1499,7 +1527,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
+class DeviceBayTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
     brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'enabled', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1541,7 +1569,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
+class InventoryItemTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = InventoryItemTemplate
     model = InventoryItemTemplate
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
     brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -1601,7 +1629,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
+class DeviceRoleTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceRole
     model = DeviceRole
     brief_fields = [
     brief_fields = [
         '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'
         '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'
@@ -1635,7 +1663,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
         DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
         DeviceRole.objects.create(name='Device Role 3', slug='device-role-3', color='0000ff')
 
 
 
 
-class PlatformTest(APIViewTestCases.APIViewTestCase):
+class PlatformTestCase(APIViewTestCases.APIViewTestCase):
     model = Platform
     model = Platform
     brief_fields = [
     brief_fields = [
         '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count',
         '_depth', 'description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count',
@@ -1670,7 +1698,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
             platform.save()
             platform.save()
 
 
 
 
-class DeviceTest(APIViewTestCases.APIViewTestCase):
+class DeviceTestCase(APIViewTestCases.APIViewTestCase):
     model = Device
     model = Device
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2020,7 +2048,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
 
 
-class ModuleTest(APIViewTestCases.APIViewTestCase):
+class ModuleTestCase(APIViewTestCases.APIViewTestCase):
     model = Module
     model = Module
     brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
     brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2355,7 +2383,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(len(response.data['results']), 1)
         self.assertEqual(len(response.data['results']), 1)
 
 
 
 
-class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     model = ConsolePort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2398,7 +2426,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
         ]
         ]
 
 
 
 
-class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2441,7 +2469,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
         ]
         ]
 
 
 
 
-class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerPort
     model = PowerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2481,7 +2509,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
         ]
         ]
 
 
 
 
-class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerOutletTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2530,7 +2558,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
         ]
         ]
 
 
 
 
-class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = Interface
     model = Interface
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2739,7 +2767,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
         self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
 
 
 
 
-class FrontPortTest(APIViewTestCases.APIViewTestCase):
+class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
     model = FrontPort
     model = FrontPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2858,7 +2886,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
 
 
-class RearPortTest(APIViewTestCases.APIViewTestCase):
+class RearPortTestCase(APIViewTestCases.APIViewTestCase):
     model = RearPort
     model = RearPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -2974,7 +3002,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
 
 
-class ModuleBayTest(APIViewTestCases.APIViewTestCase):
+class ModuleBayTestCase(APIViewTestCases.APIViewTestCase):
     model = ModuleBay
     model = ModuleBay
     brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
     brief_fields = ['_occupied', 'description', 'display', 'enabled', 'id', 'installed_module', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3016,7 +3044,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class DeviceBayTest(APIViewTestCases.APIViewTestCase):
+class DeviceBayTestCase(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
     model = DeviceBay
     brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'description', 'device', 'display', 'enabled', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3080,7 +3108,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class InventoryItemTest(APIViewTestCases.APIViewTestCase):
+class InventoryItemTestCase(APIViewTestCases.APIViewTestCase):
     model = InventoryItem
     model = InventoryItem
     brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
     brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3147,7 +3175,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
+class InventoryItemRoleTestCase(APIViewTestCases.APIViewTestCase):
     model = InventoryItemRole
     model = InventoryItemRole
     brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
     create_data = [
     create_data = [
@@ -3182,7 +3210,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
         InventoryItemRole.objects.bulk_create(roles)
         InventoryItemRole.objects.bulk_create(roles)
 
 
 
 
-class CableBundleTest(APIViewTestCases.APIViewTestCase):
+class CableBundleTestCase(APIViewTestCases.APIViewTestCase):
     model = CableBundle
     model = CableBundle
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -3236,7 +3264,7 @@ class CableBundleTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.data['cable_count'], 2)
         self.assertEqual(response.data['cable_count'], 2)
 
 
 
 
-class CableTest(APIViewTestCases.APIViewTestCase):
+class CableTestCase(APIViewTestCases.APIViewTestCase):
     model = Cable
     model = Cable
     brief_fields = ['description', 'display', 'id', 'label', 'url']
     brief_fields = ['description', 'display', 'id', 'label', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3445,7 +3473,7 @@ class CableTest(APIViewTestCases.APIViewTestCase):
                 self.assertSetEqual(set(ids), expected)
                 self.assertSetEqual(set(ids), expected)
 
 
 
 
-class CableTerminationTest(
+class CableTerminationTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
 ):
 ):
@@ -3474,7 +3502,7 @@ class CableTerminationTest(
             cable.save()
             cable.save()
 
 
 
 
-class ConnectedDeviceTest(APITestCase):
+class ConnectedDeviceTestCase(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -3497,8 +3525,8 @@ class ConnectedDeviceTest(APITestCase):
         cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
         cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
         cable.save()
         cable.save()
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_get_connected_device(self):
     def test_get_connected_device(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_interface')
         url = reverse('dcim-api:connected-device-list')
         url = reverse('dcim-api:connected-device-list')
 
 
         url_params = '?peer_device=TestDevice1&peer_interface=eth0'
         url_params = '?peer_device=TestDevice1&peer_interface=eth0'
@@ -3511,7 +3539,7 @@ class ConnectedDeviceTest(APITestCase):
         self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
         self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
 
 
 
 
-class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
+class VirtualChassisTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualChassis
     model = VirtualChassis
     brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
 
 
@@ -3592,7 +3620,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class PowerPanelTest(APIViewTestCases.APIViewTestCase):
+class PowerPanelTestCase(APIViewTestCases.APIViewTestCase):
     model = PowerPanel
     model = PowerPanel
     brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
     user_permissions = ('dcim.view_site', )
     user_permissions = ('dcim.view_site', )
@@ -3642,7 +3670,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class PowerFeedTest(APIViewTestCases.APIViewTestCase):
+class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
     model = PowerFeed
     model = PowerFeed
     brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3698,7 +3726,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
+class VirtualDeviceContextTestCase(APIViewTestCases.APIViewTestCase):
     model = VirtualDeviceContext
     model = VirtualDeviceContext
     brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
     brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -3752,7 +3780,7 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class MACAddressTest(APIViewTestCases.APIViewTestCase):
+class MACAddressTestCase(APIViewTestCases.APIViewTestCase):
     model = MACAddress
     model = MACAddress
     brief_fields = ['description', 'display', 'id', 'mac_address', 'url']
     brief_fields = ['description', 'display', 'id', 'mac_address', 'url']
     bulk_update_data = {
     bulk_update_data = {

+ 2 - 2
netbox/dcim/tests/test_cable_profiles.py

@@ -12,7 +12,7 @@ from dcim.models import Cable, Interface, RearPort
 from dcim.tests.utils import CablePathTestCase
 from dcim.tests.utils import CablePathTestCase
 
 
 
 
-class CableProfileLinkPeerTests(CablePathTestCase):
+class CableProfileLinkPeerTestCase(CablePathTestCase):
     """
     """
     Tests for link peer resolution with cable profiles.
     Tests for link peer resolution with cable profiles.
     """
     """
@@ -75,7 +75,7 @@ class CableProfileLinkPeerTests(CablePathTestCase):
             self.assertEqual(interface.link_peers, [rear_ports[1]])
             self.assertEqual(interface.link_peers, [rear_ports[1]])
 
 
 
 
-class CableProfilePeerTerminationTests(CablePathTestCase):
+class CableProfilePeerTerminationTestCase(CablePathTestCase):
     """
     """
     Tests for BaseCableProfile.get_peer_termination() and get_peer_terminations().
     Tests for BaseCableProfile.get_peer_termination() and get_peer_terminations().
     Verifies that the batch method produces identical results to calling
     Verifies that the batch method produces identical results to calling

+ 13 - 1
netbox/dcim/tests/test_cablepaths.py

@@ -6,7 +6,7 @@ from dcim.tests.utils import CablePathTestCase
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
 
 
 
 
-class LegacyCablePathTests(CablePathTestCase):
+class LegacyCablePathTestCase(CablePathTestCase):
     """
     """
     Test NetBox's ability to trace and retrace CablePaths in response to data model changes, without cable profiles.
     Test NetBox's ability to trace and retrace CablePaths in response to data model changes, without cable profiles.
 
 
@@ -55,6 +55,18 @@ class LegacyCablePathTests(CablePathTestCase):
         # Check that all CablePaths have been deleted
         # Check that all CablePaths have been deleted
         self.assertEqual(CablePath.objects.count(), 0)
         self.assertEqual(CablePath.objects.count(), 0)
 
 
+        # Check that connected interfaces are fully cleaned up
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+
+        self.assertIsNone(interface1.cable_id)
+        self.assertEqual(interface1.cable_end, '')
+        self.assertPathIsNotSet(interface1)
+
+        self.assertIsNone(interface2.cable_id)
+        self.assertEqual(interface2.cable_end, '')
+        self.assertPathIsNotSet(interface2)
+
     def test_102_consoleport_to_consoleserverport(self):
     def test_102_consoleport_to_consoleserverport(self):
         """
         """
         [CP1] --C1-- [CSP1]
         [CP1] --C1-- [CSP1]

+ 1 - 1
netbox/dcim/tests/test_cablepaths2.py

@@ -7,7 +7,7 @@ from dcim.svg import CableTraceSVG
 from dcim.tests.utils import CablePathTestCase
 from dcim.tests.utils import CablePathTestCase
 
 
 
 
-class CablePathTests(CablePathTestCase):
+class CablePathTestCase(CablePathTestCase):
     """
     """
     Test the creation of CablePaths for Cables with different profiles applied.
     Test the creation of CablePaths for Cables with different profiles applied.
 
 

+ 91 - 0
netbox/dcim/tests/test_filtersets.py

@@ -1,4 +1,5 @@
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test import TestCase
 
 
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
@@ -7050,6 +7051,96 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
+class CableTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = CableTermination.objects.all()
+    filterset = CableTerminationFilterSet
+    ignore_fields = ('connector', 'positions')
+    filter_name_map = {
+        'consoleport': 'consoleport_id',
+        'consoleserverport': 'consoleserverport_id',
+        'powerport': 'powerport_id',
+        'poweroutlet': 'poweroutlet_id',
+        'interface': 'interface_id',
+        'frontport': 'frontport_id',
+        'rearport': 'rearport_id',
+        'powerfeed': 'powerfeed_id',
+        'circuittermination': 'circuittermination_id',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        device = create_test_device('Device 1', site=site)
+
+        cls.interfaces = [
+            Interface(device=device, name=f'eth{i}', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+            for i in range(4)
+        ]
+        Interface.objects.bulk_create(cls.interfaces)
+
+        cls.consoleport = ConsolePort.objects.create(device=device, name='Console Port 1')
+        cls.consoleserverport = ConsoleServerPort.objects.create(device=device, name='Console Server Port 1')
+        cls.powerport = PowerPort.objects.create(device=device, name='Power Port 1')
+        cls.poweroutlet = PowerOutlet.objects.create(device=device, name='Power Outlet 1')
+        cls.rearport = RearPort.objects.create(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C)
+        cls.frontport = FrontPort.objects.create(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C)
+        PortMapping.objects.create(device=device, front_port=cls.frontport, rear_port=cls.rearport)
+
+        power_panel = PowerPanel.objects.create(name='Power Panel 1', site=site)
+        cls.powerfeed = PowerFeed.objects.create(name='Power Feed 1', power_panel=power_panel)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+        circuit = Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type)
+        cls.circuittermination = CircuitTermination.objects.create(
+            circuit=circuit, term_side='A', termination=site,
+        )
+
+        # Two bipartite interface cables (4 CableTerminations) plus one single-end cable per
+        # non-Interface component (8 CableTerminations).
+        cables = [
+            Cable(a_terminations=[cls.interfaces[0]], b_terminations=[cls.interfaces[1]], label='Cable 1'),
+            Cable(a_terminations=[cls.interfaces[2]], b_terminations=[cls.interfaces[3]], label='Cable 2'),
+        ]
+        for component in (
+            cls.consoleport, cls.consoleserverport, cls.powerport, cls.poweroutlet,
+            cls.rearport, cls.frontport, cls.powerfeed, cls.circuittermination,
+        ):
+            cables.append(Cable(a_terminations=[component], label=f'Cable for {component._meta.model_name}'))
+        for cable in cables:
+            cable.save()
+
+    def test_cable(self):
+        """Filter CableTerminations by cable ID."""
+        cables = Cable.objects.all()[:2]
+        params = {'cable_id': [cables[0].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cable_id': [cables[0].pk, cables[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_termination_object_filters(self):
+        """Each <component>_id filter resolves to only matching terminations."""
+        cases = (
+            ('consoleport_id', ConsolePort, self.consoleport),
+            ('consoleserverport_id', ConsoleServerPort, self.consoleserverport),
+            ('powerport_id', PowerPort, self.powerport),
+            ('poweroutlet_id', PowerOutlet, self.poweroutlet),
+            ('interface_id', Interface, self.interfaces[0]),
+            ('frontport_id', FrontPort, self.frontport),
+            ('rearport_id', RearPort, self.rearport),
+            ('powerfeed_id', PowerFeed, self.powerfeed),
+            ('circuittermination_id', CircuitTermination, self.circuittermination),
+        )
+        for filter_name, model, obj in cases:
+            with self.subTest(filter_name=filter_name):
+                ct = ContentType.objects.get_for_model(model)
+                params = {filter_name: [obj.pk]}
+                results = self.filterset(params, self.queryset).qs
+                self.assertEqual(results.count(), 1)
+                self.assertEqual(results.first().termination_type, ct)
+                self.assertEqual(results.first().termination_id, obj.pk)
+
+
 class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
 class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = PowerPanel.objects.all()
     queryset = PowerPanel.objects.all()
     filterset = PowerPanelFilterSet
     filterset = PowerPanelFilterSet

+ 150 - 0
netbox/dcim/tests/test_management_commands.py

@@ -0,0 +1,150 @@
+import json
+import tempfile
+from io import StringIO
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.core.management import call_command
+from django.test import TestCase, override_settings
+
+
+class BuildSchemaTestCase(TestCase):
+    def test_output_is_valid_json(self):
+        out = StringIO()
+
+        call_command('buildschema', stdout=out)
+
+        self.assertIsInstance(json.loads(out.getvalue()), dict)
+
+    def test_write_flag_writes_schema_to_configured_base_dir(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            base_dir = Path(tmpdir) / 'netbox'
+            output_dir = Path(tmpdir) / 'contrib'
+            output_dir.mkdir()
+
+            out = StringIO()
+            with override_settings(BASE_DIR=base_dir):
+                call_command('buildschema', write=True, stdout=out)
+
+            output_file = output_dir / 'generated_schema.json'
+            self.assertTrue(output_file.exists())
+            self.assertIsInstance(json.loads(output_file.read_text(encoding='utf-8')), dict)
+            self.assertIn(str(output_file), out.getvalue())
+
+
+class TracePathsTestCase(TestCase):
+    def test_no_cables(self):
+        out = StringIO()
+
+        call_command('trace_paths', no_input=True, stdout=out)
+
+        self.assertIn('Finished.', out.getvalue())
+
+    def test_force_no_cable_paths(self):
+        out = StringIO()
+
+        call_command('trace_paths', force=True, no_input=True, stdout=out)
+
+        self.assertIn('Finished.', out.getvalue())
+
+    def test_retraces_missing_cabled_endpoint_path(self):
+        endpoint = object()
+
+        class FakeQuerySet(list):
+            def filter(self, *args, **kwargs):
+                return self
+
+            def count(self):
+                return len(self)
+
+        class FakeObjects:
+            def filter(self, *args, **kwargs):
+                return FakeQuerySet([endpoint])
+
+        model = SimpleNamespace(
+            objects=FakeObjects(),
+            wireless_link=object(),
+            _meta=SimpleNamespace(verbose_name='interface', verbose_name_plural='interfaces'),
+        )
+        out = StringIO()
+
+        with (
+            patch('dcim.management.commands.trace_paths.ENDPOINT_MODELS', (model,)),
+            patch('dcim.management.commands.trace_paths.create_cablepaths') as create_cablepaths,
+        ):
+            call_command('trace_paths', no_input=True, stdout=out)
+
+        create_cablepaths.assert_called_once_with([endpoint])
+        self.assertIn('Retracing 1 cabled interfaces', out.getvalue())
+        self.assertIn('Retraced 1 interfaces', out.getvalue())
+        self.assertIn('Finished.', out.getvalue())
+
+    def test_progress_bar_drawn_every_100_endpoints(self):
+        endpoints = [object() for _ in range(100)]
+
+        class FakeQuerySet(list):
+            def filter(self, *args, **kwargs):
+                return self
+
+            def count(self):
+                return len(self)
+
+        class FakeObjects:
+            def filter(self, *args, **kwargs):
+                return FakeQuerySet(endpoints)
+
+        model = SimpleNamespace(
+            objects=FakeObjects(),
+            wireless_link=object(),
+            _meta=SimpleNamespace(verbose_name='interface', verbose_name_plural='interfaces'),
+        )
+        out = StringIO()
+
+        with (
+            patch('dcim.management.commands.trace_paths.ENDPOINT_MODELS', (model,)),
+            patch('dcim.management.commands.trace_paths.create_cablepaths'),
+        ):
+            call_command('trace_paths', no_input=True, stdout=out)
+
+        self.assertIn('[####################] 100%', out.getvalue())
+        self.assertIn('Retraced 100 interfaces', out.getvalue())
+
+    def test_force_aborts_when_confirmation_is_not_yes(self):
+        out = StringIO()
+        cable_paths = MagicMock()
+        cable_paths.count.return_value = 1
+
+        with (
+            patch('dcim.management.commands.trace_paths.CablePath') as cable_path_model,
+            patch('builtins.input', return_value='no'),
+        ):
+            cable_path_model.objects.all.return_value = cable_paths
+            call_command('trace_paths', force=True, stdout=out)
+
+        cable_paths.delete.assert_not_called()
+        self.assertIn('WARNING: Forcing recalculation', out.getvalue())
+        self.assertIn('Aborting', out.getvalue())
+
+    def test_force_deletes_existing_paths_and_resets_sequence(self):
+        out = StringIO()
+        cable_paths = MagicMock()
+        cable_paths.count.return_value = 2
+        cable_paths.delete.return_value = (2, {})
+
+        with (
+            patch('dcim.management.commands.trace_paths.CablePath') as cable_path_model,
+            patch('dcim.management.commands.trace_paths.ENDPOINT_MODELS', ()),
+            patch('dcim.management.commands.trace_paths.connection') as connection,
+        ):
+            cable_path_model.objects.all.return_value = cable_paths
+            connection.ops.sequence_reset_sql.return_value = ['RESET SEQUENCE']
+            cursor = connection.cursor.return_value.__enter__.return_value
+
+            call_command('trace_paths', force=True, no_input=True, stdout=out)
+
+        cable_paths.delete.assert_called_once_with()
+        cursor.execute.assert_called_once_with('RESET SEQUENCE')
+        self.assertIn('Deleting 2 existing cable paths', out.getvalue())
+        self.assertIn('Deleted 2 paths', out.getvalue())
+        self.assertIn('Finished.', out.getvalue())

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

@@ -915,6 +915,214 @@ class ModuleBayTestCase(TestCase):
             module_1.clean()
             module_1.clean()
             module_1.save()
             module_1.save()
 
 
+    @tag('regression')  # #22146
+    def test_module_bay_ordering_after_recreate(self):
+        """
+        Module bays must remain in name order after a delete-and-recreate cycle,
+        even though MPTT no longer renumbers tree_ids on root insertion.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Ordering Test Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'):
+            ModuleBay.objects.create(device=device, name=name)
+
+        ModuleBay.objects.get(device=device, name='Bay 3').delete()
+        ModuleBay.objects.create(device=device, name='Bay 3')
+
+        names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
+        self.assertEqual(names, ['Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'])
+
+    @tag('regression')  # #22146
+    def test_module_bay_natural_ordering(self):
+        """
+        Module bays must be returned in natural (numeric-aware) order, e.g.
+        "Bay 2" before "Bay 10".
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Natural Sort Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        # Insert in non-natural order to confirm sort is not insertion-driven.
+        for name in ('Bay 10', 'Bay 1', 'Bay 2', 'Bay 11'):
+            ModuleBay.objects.create(device=device, name=name)
+
+        names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
+        self.assertEqual(names, ['Bay 1', 'Bay 2', 'Bay 10', 'Bay 11'])
+
+    @tag('regression')  # #22146
+    def test_child_module_bay_ordering(self):
+        """
+        Child module bays inside a module must be returned in name order even
+        when inserted out of order.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Child Ordering Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        root_bay = ModuleBay.objects.create(device=device, name='Bay 1')
+        manufacturer = Manufacturer.objects.first()
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Child Ordering Type'
+        )
+        module = Module.objects.create(
+            device=device, module_bay=root_bay, module_type=module_type
+        )
+        # Insert children out of name order.
+        for name in ('Bay 1.1', 'Bay 1.3', 'Bay 1.2'):
+            ModuleBay.objects.create(device=device, module=module, name=name)
+
+        names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
+        self.assertEqual(names, ['Bay 1', 'Bay 1.1', 'Bay 1.2', 'Bay 1.3'])
+
+    @tag('regression')  # #22146
+    def test_root_module_bay_rename_preserves_tree_ids(self):
+        """
+        Renaming a root module bay must not renumber any other root tree's
+        tree_id. The renamed bay's own tree_id is also expected to remain
+        stable, but the load-bearing assertion is that the *other* bays are
+        not shifted.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Rename TreeID Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        for name in ('Bay 1', 'Bay 2', 'Bay 3', 'Bay 4'):
+            ModuleBay.objects.create(device=device, name=name)
+
+        tree_ids_before = {
+            bay.name: bay.tree_id
+            for bay in ModuleBay.objects.filter(device=device)
+        }
+
+        bay = ModuleBay.objects.get(device=device, name='Bay 2')
+        bay.name = 'Bay 99'
+        bay.save()
+
+        tree_ids_after = {
+            bay.name: bay.tree_id
+            for bay in ModuleBay.objects.filter(device=device)
+        }
+        for name in ('Bay 1', 'Bay 3', 'Bay 4'):
+            self.assertEqual(tree_ids_after[name], tree_ids_before[name])
+        self.assertEqual(tree_ids_after['Bay 99'], tree_ids_before['Bay 2'])
+
+    @tag('regression')  # #22146
+    def test_root_module_bay_rename_updates_display_order(self):
+        """
+        Even though renaming a root module bay does not renumber tree_ids,
+        the manager's _root_name annotation must reflect the new name so the
+        display ordering is correct.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Rename Order Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        for name in ('Bay 1', 'Bay 2', 'Bay 3'):
+            ModuleBay.objects.create(device=device, name=name)
+
+        bay = ModuleBay.objects.get(device=device, name='Bay 1')
+        bay.name = 'Bay 4'
+        bay.save()
+
+        names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
+        self.assertEqual(names, ['Bay 2', 'Bay 3', 'Bay 4'])
+
+    @tag('regression')  # #22146
+    def test_child_module_bay_rename_preserves_intra_tree_ordering(self):
+        """
+        Renaming a *child* module bay must still trigger MPTT's intra-tree
+        reorder, so siblings appear in name order after the rename. The
+        rename-bypass only covers root bays.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Child Rename Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        root_bay = ModuleBay.objects.create(device=device, name='Bay 1')
+        manufacturer = Manufacturer.objects.first()
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Child Rename Type'
+        )
+        module = Module.objects.create(
+            device=device, module_bay=root_bay, module_type=module_type
+        )
+        for name in ('Bay 1.1', 'Bay 1.2', 'Bay 1.3'):
+            ModuleBay.objects.create(device=device, module=module, name=name)
+
+        child = ModuleBay.objects.get(device=device, name='Bay 1.1')
+        child.name = 'Bay 1.4'
+        child.save()
+
+        names = list(ModuleBay.objects.filter(device=device).values_list('name', flat=True))
+        self.assertEqual(names, ['Bay 1', 'Bay 1.2', 'Bay 1.3', 'Bay 1.4'])
+
+    @tag('regression')  # #22146
+    def test_root_to_child_transition_still_relocates(self):
+        """
+        Promoting an existing root module bay to a child (by assigning a
+        module) must still flow through MPTT's normal move logic. The
+        rename-bypass must not suppress legitimate parent changes.
+        """
+        device_type = DeviceType.objects.first()
+        device_role = DeviceRole.objects.first()
+        site = Site.objects.first()
+        device = Device.objects.create(
+            name='Root To Child Device',
+            device_type=device_type,
+            role=device_role,
+            site=site,
+        )
+        host_bay = ModuleBay.objects.create(device=device, name='Host Bay')
+        movable_bay = ModuleBay.objects.create(device=device, name='Movable Bay')
+
+        manufacturer = Manufacturer.objects.first()
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer, model='Root To Child Type'
+        )
+        host_module = Module.objects.create(
+            device=device, module_bay=host_bay, module_type=module_type
+        )
+
+        movable_bay.module = host_module
+        movable_bay.save()
+
+        movable_bay.refresh_from_db()
+        host_bay.refresh_from_db()
+        self.assertEqual(movable_bay.parent_id, host_bay.pk)
+        self.assertEqual(movable_bay.tree_id, host_bay.tree_id)
+
     def test_single_module_token(self):
     def test_single_module_token(self):
         device_type = DeviceType.objects.first()
         device_type = DeviceType.objects.first()
         device_role = DeviceRole.objects.first()
         device_role = DeviceRole.objects.first()

+ 488 - 0
netbox/dcim/tests/test_signals.py

@@ -0,0 +1,488 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.test import SimpleTestCase, TestCase
+
+from dcim import signals
+from dcim.choices import CableEndChoices, LinkStatusChoices
+from dcim.models import (
+    Cable,
+    CablePath,
+    Device,
+    DeviceRole,
+    DeviceType,
+    FrontPort,
+    Interface,
+    Location,
+    MACAddress,
+    Manufacturer,
+    PortMapping,
+    PowerPanel,
+    Rack,
+    RearPort,
+    Site,
+    SiteGroup,
+    VirtualChassis,
+)
+from ipam.models import Prefix
+from virtualization.models import Cluster, ClusterType
+from wireless.models import WirelessLAN
+
+
+class LocationSiteChangeSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.handle_location_site_change propagates a Location's new Site to
+    every descendant Location, Rack, Device, PowerPanel, and component when the parent
+    Location's site assignment changes.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+
+    def test_changing_location_site_propagates_to_children(self):
+        parent_location = Location.objects.create(name='Parent', slug='parent', site=self.site_a)
+        child_location = Location.objects.create(name='Child', slug='child', site=self.site_a, parent=parent_location)
+        rack = Rack.objects.create(name='Rack', site=self.site_a, location=parent_location)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            location=parent_location,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+        power_panel = PowerPanel.objects.create(name='Panel', site=self.site_a, location=parent_location)
+
+        parent_location.site = self.site_b
+        parent_location.save()
+
+        child_location.refresh_from_db()
+        rack.refresh_from_db()
+        device.refresh_from_db()
+        interface.refresh_from_db()
+        power_panel.refresh_from_db()
+        self.assertEqual(child_location.site, self.site_b)
+        self.assertEqual(rack.site, self.site_b)
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(interface._site, self.site_b)
+        self.assertEqual(power_panel.site, self.site_b)
+
+    def test_creating_location_does_not_attempt_to_propagate(self):
+        # Should not raise — newly-created locations have no descendants.
+        Location.objects.create(name='New', slug='new', site=self.site_a)
+
+
+class RackSiteChangeSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its
+    Devices and their components when the Rack is moved.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        cls.location_b = Location.objects.create(name='Loc B', slug='loc-b', site=cls.site_b)
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+
+    def test_changing_rack_site_propagates_to_devices_and_components(self):
+        rack = Rack.objects.create(name='Rack', site=self.site_a)
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            rack=rack,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+
+        rack.site = self.site_b
+        rack.location = self.location_b
+        rack.save()
+
+        device.refresh_from_db()
+        interface.refresh_from_db()
+        self.assertEqual(device.site, self.site_b)
+        self.assertEqual(device.location, self.location_b)
+        self.assertEqual(interface._site, self.site_b)
+        self.assertEqual(interface._location, self.location_b)
+
+
+class DeviceSiteChangeSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
+    to its components on save.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_a = Site.objects.create(name='Site A', slug='site-a')
+        cls.site_b = Site.objects.create(name='Site B', slug='site-b')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+
+    def test_moving_device_updates_components_cached_scope(self):
+        device = Device.objects.create(
+            name='Device',
+            site=self.site_a,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        interface = Interface.objects.create(device=device, name='Interface 1')
+        self.assertEqual(interface._site, self.site_a)
+
+        device.site = self.site_b
+        device.save()
+
+        interface.refresh_from_db()
+        self.assertEqual(interface._site, self.site_b)
+
+
+class VirtualChassisMasterSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.assign_virtualchassis_master links the master device back to a
+    newly-created VirtualChassis.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site', slug='site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+
+    def test_master_is_assigned_to_new_virtual_chassis(self):
+        master = Device.objects.create(
+            name='Master',
+            site=self.site,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        vc = VirtualChassis.objects.create(name='VC 1', master=master)
+
+        master.refresh_from_db()
+        self.assertEqual(master.virtual_chassis, vc)
+        self.assertEqual(master.vc_position, 1)
+
+    def test_updating_virtual_chassis_does_not_reassign_master(self):
+        master = Device.objects.create(
+            name='Master',
+            site=self.site,
+            device_type=self.device_type,
+            role=self.device_role,
+        )
+        vc = VirtualChassis.objects.create(name='VC 1', master=master)
+
+        # Detach the master, then save the VC again — the signal should not re-link.
+        master.virtual_chassis = None
+        master.vc_position = None
+        master.save()
+
+        vc.domain = 'updated'
+        vc.save()
+
+        master.refresh_from_db()
+        self.assertIsNone(master.virtual_chassis)
+
+
+class CableSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.update_connected_endpoints, retrace_cable_paths, and
+    nullify_connected_endpoints maintain CablePaths in response to Cable lifecycle events.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site', slug='site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        cls.device = Device.objects.create(
+            name='Device',
+            site=cls.site,
+            device_type=device_type,
+            role=role,
+        )
+
+    def test_creating_cable_creates_endpoint_paths(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface_a.refresh_from_db()
+        self.assertIsNotNone(interface_a._path_id)
+
+    def test_changing_cable_status_marks_paths_inactive(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+        self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
+
+        # Reload the cable so _orig_status reflects the persisted value and
+        # _terminations_modified resets to False.
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.status = LinkStatusChoices.STATUS_PLANNED
+        cable.save()
+
+        self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
+
+    def test_reconnecting_cable_marks_paths_active(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(
+            a_terminations=[interface_a],
+            b_terminations=[interface_b],
+            status=LinkStatusChoices.STATUS_PLANNED,
+        )
+        cable.save()
+        self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
+
+        cable = Cable.objects.get(pk=cable.pk)
+        cable.status = LinkStatusChoices.STATUS_CONNECTED
+        cable.save()
+
+        self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
+
+    def test_deleting_cable_retraces_paths(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        cable.delete()
+        self.assertEqual(CablePath.objects.count(), 0)
+        interface_a.refresh_from_db()
+        interface_b.refresh_from_db()
+        # Cable deletion must fully detach both endpoints, even though the
+        # nullify_connected_endpoints signal short-circuits during Cable cascade.
+        self.assertIsNone(interface_a._path_id)
+        self.assertIsNone(interface_b._path_id)
+        self.assertIsNone(interface_a.cable_id)
+        self.assertIsNone(interface_b.cable_id)
+        self.assertEqual(interface_a.cable_end, '')
+        self.assertEqual(interface_b.cable_end, '')
+
+    def test_deleting_cable_skips_per_termination_retrace(self):
+        """
+        When a Cable is deleted, nullify_connected_endpoints (post_delete on each
+        cascaded CableTermination) must skip retracing — retrace_cable_paths
+        retraces each affected path once on Cable post_delete instead. See #22104.
+
+        Without the short-circuit, retrace would fire (n_terminations * n_paths)
+        times from the per-termination handler plus n_paths times from the Cable
+        handler — for this 2-termination, 2-path cable, 6 calls total. With the
+        short-circuit, only the n_paths calls from retrace_cable_paths remain.
+        """
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+        self.assertFalse(Cable._is_being_deleted(cable.pk))
+
+        with patch('dcim.models.cables.CablePath.retrace') as retrace:
+            cable.delete()
+
+        # Exactly one retrace per affected CablePath (from retrace_cable_paths),
+        # not the n*m calls the per-termination handler would have made.
+        self.assertEqual(retrace.call_count, 2)
+        # The deletion-tracking set must be cleaned up after delete() returns,
+        # even when the cascade runs to completion.
+        self.assertFalse(Cable._is_being_deleted(cable.pk))
+
+    def test_creating_portmapping_retraces_dependent_paths(self):
+        interface = Interface.objects.create(device=self.device, name='Interface A')
+        front_port = FrontPort.objects.create(device=self.device, name='Front Port 1')
+        rear_port = RearPort.objects.create(device=self.device, name='Rear Port 1')
+        Cable(a_terminations=[interface], b_terminations=[front_port]).save()
+
+        # Creating a PortMapping connecting the front and rear ports should retrace paths
+        # that traverse either port (i.e. the incomplete path through front_port).
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=front_port,
+            front_port_position=1,
+            rear_port=rear_port,
+            rear_port_position=1,
+        )
+
+        path = CablePath.objects.filter(_nodes__contains=front_port).first()
+        self.assertIsNotNone(path)
+        # The retraced path should now extend through to the rear port. Path nodes are
+        # encoded as "<content_type_id>:<object_id>".
+        rear_port_node = f'{ContentType.objects.get_for_model(RearPort).pk}:{rear_port.pk}'
+        flat_nodes = [n for step in path.path for n in step]
+        self.assertIn(rear_port_node, flat_nodes)
+
+    def test_deleting_cabletermination_nullifies_endpoints(self):
+        interface_a = Interface.objects.create(device=self.device, name='Interface A')
+        interface_b = Interface.objects.create(device=self.device, name='Interface B')
+        cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
+        cable.save()
+        termination = cable.terminations.get(cable_end=CableEndChoices.SIDE_A)
+
+        termination.delete()
+        interface_a.refresh_from_db()
+        self.assertIsNone(interface_a.cable_id)
+        self.assertEqual(interface_a.cable_end, '')
+
+
+class MACAddressInterfaceSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.update_mac_address_interface assigns a designated primary MAC to
+    the newly-created Interface or VMInterface.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site = Site.objects.create(name='Site', slug='site')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        cls.device = Device.objects.create(
+            name='Device',
+            site=cls.site,
+            device_type=device_type,
+            role=role,
+        )
+
+    def test_primary_mac_is_assigned_to_new_interface(self):
+        mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
+        interface = Interface(device=self.device, name='Interface 1', primary_mac_address=mac)
+        interface.save()
+
+        mac.refresh_from_db()
+        self.assertEqual(mac.assigned_object, interface)
+
+    def test_primary_mac_is_not_reassigned_on_interface_update(self):
+        mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
+        interface = Interface.objects.create(device=self.device, name='Interface 1')
+        mac.assigned_object = interface
+        mac.save()
+        # Detach (simulate the MAC having been moved off the interface).
+        mac.assigned_object = None
+        mac.save()
+
+        interface.primary_mac_address = mac
+        interface.description = 'updated'
+        interface.save()
+
+        mac.refresh_from_db()
+        # Updating an existing interface should not re-assign the MAC.
+        self.assertIsNone(mac.assigned_object)
+
+
+class SyncCachedScopeFieldsSignalTestCase(TestCase):
+    """
+    Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on
+    Prefix, Cluster, and WirelessLAN when a Site or Location is modified.
+    """
+
+    def test_site_group_change_updates_prefix_cached_scope(self):
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site = Site.objects.create(name='Site', slug='site', group=group_a)
+        prefix = Prefix.objects.create(
+            prefix='10.0.0.0/24',
+            scope_type=ContentType.objects.get_for_model(Site),
+            scope_id=site.pk,
+        )
+        self.assertEqual(prefix._site_group, group_a)
+
+        site.group = group_b
+        site.save()
+
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._site, site)
+        self.assertEqual(prefix._site_group, group_b)
+
+    def test_location_site_change_updates_prefix_cached_scope(self):
+        site_a = Site.objects.create(name='Site A', slug='site-a')
+        site_b = Site.objects.create(name='Site B', slug='site-b')
+        location = Location.objects.create(name='Loc', slug='loc', site=site_a)
+        prefix = Prefix.objects.create(
+            prefix='10.0.0.0/24',
+            scope_type=ContentType.objects.get_for_model(Location),
+            scope_id=location.pk,
+        )
+        self.assertEqual(prefix._site, site_a)
+        self.assertEqual(prefix._location, location)
+
+        location.site = site_b
+        location.save()
+
+        prefix.refresh_from_db()
+        self.assertEqual(prefix._location, location)
+        self.assertEqual(prefix._site, site_b)
+
+    def test_signal_updates_cluster_and_wirelesslan_cached_scope(self):
+        # Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the
+        # signal by exercising Cluster and WirelessLAN alongside Prefix. If a
+        # future change drops Cluster or WirelessLAN from that tuple, this test
+        # will catch it.
+        group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
+        group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
+        site = Site.objects.create(name='Site', slug='site', group=group_a)
+        cluster_type = ClusterType.objects.create(name='CT', slug='ct')
+        cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site)
+        wireless_lan = WirelessLAN.objects.create(ssid='LAN', scope=site)
+
+        self.assertEqual(cluster._site_group, group_a)
+        self.assertEqual(wireless_lan._site_group, group_a)
+
+        site.group = group_b
+        site.save()
+
+        cluster.refresh_from_db()
+        wireless_lan.refresh_from_db()
+        self.assertEqual(cluster._site_group, group_b)
+        self.assertEqual(wireless_lan._site_group, group_b)
+
+    def test_create_site_does_not_attempt_to_resync(self):
+        # Should not raise — newly-created sites have nothing to sync.
+        Site.objects.create(name='New Site', slug='new-site')
+
+
+class CableSignalDirectHandlerTestCase(SimpleTestCase):
+    """
+    Direct-call tests for dcim signal branches that are not reachable through normal
+    model operations (raw=True is set only by Django's loaddata pathway).
+    """
+
+    def test_update_connected_endpoints_raw_import_is_a_no_op(self):
+        cable = SimpleNamespace(_terminations_modified=True)
+        logger = MagicMock()
+
+        with (
+            patch.object(signals.logging, 'getLogger', return_value=logger),
+            patch.object(signals, 'CableTermination') as cabletermination_model,
+            patch.object(signals, 'create_cablepaths') as create_cablepaths,
+            patch.object(signals, 'rebuild_paths') as rebuild_paths,
+        ):
+            signals.update_connected_endpoints(instance=cable, created=True, raw=True)
+
+        logger.debug.assert_called_once()
+        cabletermination_model.objects.filter.assert_not_called()
+        create_cablepaths.assert_not_called()
+        rebuild_paths.assert_not_called()
+
+    def test_update_mac_address_interface_raw_import_is_a_no_op(self):
+        primary_mac = SimpleNamespace(save=MagicMock())
+        interface = SimpleNamespace(primary_mac_address=primary_mac)
+
+        signals.update_mac_address_interface(instance=interface, created=True, raw=True)
+
+        primary_mac.save.assert_not_called()

+ 44 - 36
netbox/dcim/tests/test_tables.py

@@ -7,19 +7,19 @@ from utilities.testing import TableTestCases
 #
 #
 
 
 
 
-class RegionTableTest(TableTestCases.StandardTableTestCase):
+class RegionTableTestCase(TableTestCases.StandardTableTestCase):
     table = RegionTable
     table = RegionTable
 
 
 
 
-class SiteGroupTableTest(TableTestCases.StandardTableTestCase):
+class SiteGroupTableTestCase(TableTestCases.StandardTableTestCase):
     table = SiteGroupTable
     table = SiteGroupTable
 
 
 
 
-class SiteTableTest(TableTestCases.StandardTableTestCase):
+class SiteTableTestCase(TableTestCases.StandardTableTestCase):
     table = SiteTable
     table = SiteTable
 
 
 
 
-class LocationTableTest(TableTestCases.StandardTableTestCase):
+class LocationTableTestCase(TableTestCases.StandardTableTestCase):
     table = LocationTable
     table = LocationTable
 
 
 
 
@@ -27,19 +27,23 @@ class LocationTableTest(TableTestCases.StandardTableTestCase):
 # Racks
 # Racks
 #
 #
 
 
-class RackRoleTableTest(TableTestCases.StandardTableTestCase):
+class RackRoleTableTestCase(TableTestCases.StandardTableTestCase):
     table = RackRoleTable
     table = RackRoleTable
 
 
 
 
-class RackTypeTableTest(TableTestCases.StandardTableTestCase):
+class RackGroupTableTestCase(TableTestCases.StandardTableTestCase):
+    table = RackGroupTable
+
+
+class RackTypeTableTestCase(TableTestCases.StandardTableTestCase):
     table = RackTypeTable
     table = RackTypeTable
 
 
 
 
-class RackTableTest(TableTestCases.StandardTableTestCase):
+class RackTableTestCase(TableTestCases.StandardTableTestCase):
     table = RackTable
     table = RackTable
 
 
 
 
-class RackReservationTableTest(TableTestCases.StandardTableTestCase):
+class RackReservationTableTestCase(TableTestCases.StandardTableTestCase):
     table = RackReservationTable
     table = RackReservationTable
 
 
 
 
@@ -47,11 +51,11 @@ class RackReservationTableTest(TableTestCases.StandardTableTestCase):
 # Device types
 # Device types
 #
 #
 
 
-class ManufacturerTableTest(TableTestCases.StandardTableTestCase):
+class ManufacturerTableTestCase(TableTestCases.StandardTableTestCase):
     table = ManufacturerTable
     table = ManufacturerTable
 
 
 
 
-class DeviceTypeTableTest(TableTestCases.StandardTableTestCase):
+class DeviceTypeTableTestCase(TableTestCases.StandardTableTestCase):
     table = DeviceTypeTable
     table = DeviceTypeTable
 
 
 
 
@@ -59,15 +63,15 @@ class DeviceTypeTableTest(TableTestCases.StandardTableTestCase):
 # Module types
 # Module types
 #
 #
 
 
-class ModuleTypeProfileTableTest(TableTestCases.StandardTableTestCase):
+class ModuleTypeProfileTableTestCase(TableTestCases.StandardTableTestCase):
     table = ModuleTypeProfileTable
     table = ModuleTypeProfileTable
 
 
 
 
-class ModuleTypeTableTest(TableTestCases.StandardTableTestCase):
+class ModuleTypeTableTestCase(TableTestCases.StandardTableTestCase):
     table = ModuleTypeTable
     table = ModuleTypeTable
 
 
 
 
-class ModuleTableTest(TableTestCases.StandardTableTestCase):
+class ModuleTableTestCase(TableTestCases.StandardTableTestCase):
     table = ModuleTable
     table = ModuleTable
 
 
     def test_profile_column_available(self):
     def test_profile_column_available(self):
@@ -78,15 +82,15 @@ class ModuleTableTest(TableTestCases.StandardTableTestCase):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceRoleTableTest(TableTestCases.StandardTableTestCase):
+class DeviceRoleTableTestCase(TableTestCases.StandardTableTestCase):
     table = DeviceRoleTable
     table = DeviceRoleTable
 
 
 
 
-class PlatformTableTest(TableTestCases.StandardTableTestCase):
+class PlatformTableTestCase(TableTestCases.StandardTableTestCase):
     table = PlatformTable
     table = PlatformTable
 
 
 
 
-class DeviceTableTest(TableTestCases.StandardTableTestCase):
+class DeviceTableTestCase(TableTestCases.StandardTableTestCase):
     table = DeviceTable
     table = DeviceTable
 
 
 
 
@@ -94,47 +98,47 @@ class DeviceTableTest(TableTestCases.StandardTableTestCase):
 # Device components
 # Device components
 #
 #
 
 
-class ConsolePortTableTest(TableTestCases.StandardTableTestCase):
+class ConsolePortTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConsolePortTable
     table = ConsolePortTable
 
 
 
 
-class ConsoleServerPortTableTest(TableTestCases.StandardTableTestCase):
+class ConsoleServerPortTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConsoleServerPortTable
     table = ConsoleServerPortTable
 
 
 
 
-class PowerPortTableTest(TableTestCases.StandardTableTestCase):
+class PowerPortTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerPortTable
     table = PowerPortTable
 
 
 
 
-class PowerOutletTableTest(TableTestCases.StandardTableTestCase):
+class PowerOutletTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerOutletTable
     table = PowerOutletTable
 
 
 
 
-class InterfaceTableTest(TableTestCases.StandardTableTestCase):
+class InterfaceTableTestCase(TableTestCases.StandardTableTestCase):
     table = InterfaceTable
     table = InterfaceTable
 
 
 
 
-class FrontPortTableTest(TableTestCases.StandardTableTestCase):
+class FrontPortTableTestCase(TableTestCases.StandardTableTestCase):
     table = FrontPortTable
     table = FrontPortTable
 
 
 
 
-class RearPortTableTest(TableTestCases.StandardTableTestCase):
+class RearPortTableTestCase(TableTestCases.StandardTableTestCase):
     table = RearPortTable
     table = RearPortTable
 
 
 
 
-class ModuleBayTableTest(TableTestCases.StandardTableTestCase):
+class ModuleBayTableTestCase(TableTestCases.StandardTableTestCase):
     table = ModuleBayTable
     table = ModuleBayTable
 
 
 
 
-class DeviceBayTableTest(TableTestCases.StandardTableTestCase):
+class DeviceBayTableTestCase(TableTestCases.StandardTableTestCase):
     table = DeviceBayTable
     table = DeviceBayTable
 
 
 
 
-class InventoryItemTableTest(TableTestCases.StandardTableTestCase):
+class InventoryItemTableTestCase(TableTestCases.StandardTableTestCase):
     table = InventoryItemTable
     table = InventoryItemTable
 
 
 
 
-class InventoryItemRoleTableTest(TableTestCases.StandardTableTestCase):
+class InventoryItemRoleTableTestCase(TableTestCases.StandardTableTestCase):
     table = InventoryItemRoleTable
     table = InventoryItemRoleTable
 
 
 
 
@@ -142,21 +146,21 @@ class InventoryItemRoleTableTest(TableTestCases.StandardTableTestCase):
 # Connections
 # Connections
 #
 #
 
 
-class ConsoleConnectionTableTest(TableTestCases.StandardTableTestCase):
+class ConsoleConnectionTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConsoleConnectionTable
     table = ConsoleConnectionTable
     queryset_sources = [
     queryset_sources = [
         ('ConsoleConnectionsListView', ConsolePort.objects.filter(_path__is_complete=True)),
         ('ConsoleConnectionsListView', ConsolePort.objects.filter(_path__is_complete=True)),
     ]
     ]
 
 
 
 
-class PowerConnectionTableTest(TableTestCases.StandardTableTestCase):
+class PowerConnectionTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerConnectionTable
     table = PowerConnectionTable
     queryset_sources = [
     queryset_sources = [
         ('PowerConnectionsListView', PowerPort.objects.filter(_path__is_complete=True)),
         ('PowerConnectionsListView', PowerPort.objects.filter(_path__is_complete=True)),
     ]
     ]
 
 
 
 
-class InterfaceConnectionTableTest(TableTestCases.StandardTableTestCase):
+class InterfaceConnectionTableTestCase(TableTestCases.StandardTableTestCase):
     table = InterfaceConnectionTable
     table = InterfaceConnectionTable
     queryset_sources = [
     queryset_sources = [
         ('InterfaceConnectionsListView', Interface.objects.filter(_path__is_complete=True)),
         ('InterfaceConnectionsListView', Interface.objects.filter(_path__is_complete=True)),
@@ -167,19 +171,23 @@ class InterfaceConnectionTableTest(TableTestCases.StandardTableTestCase):
 # Cables
 # Cables
 #
 #
 
 
-class CableTableTest(TableTestCases.StandardTableTestCase):
+class CableTableTestCase(TableTestCases.StandardTableTestCase):
     table = CableTable
     table = CableTable
 
 
 
 
+class CableBundleTableTestCase(TableTestCases.StandardTableTestCase):
+    table = CableBundleTable
+
+
 #
 #
 # Power
 # Power
 #
 #
 
 
-class PowerPanelTableTest(TableTestCases.StandardTableTestCase):
+class PowerPanelTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerPanelTable
     table = PowerPanelTable
 
 
 
 
-class PowerFeedTableTest(TableTestCases.StandardTableTestCase):
+class PowerFeedTableTestCase(TableTestCases.StandardTableTestCase):
     table = PowerFeedTable
     table = PowerFeedTable
 
 
 
 
@@ -187,7 +195,7 @@ class PowerFeedTableTest(TableTestCases.StandardTableTestCase):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisTableTest(TableTestCases.StandardTableTestCase):
+class VirtualChassisTableTestCase(TableTestCases.StandardTableTestCase):
     table = VirtualChassisTable
     table = VirtualChassisTable
 
 
 
 
@@ -195,7 +203,7 @@ class VirtualChassisTableTest(TableTestCases.StandardTableTestCase):
 # Virtual device contexts
 # Virtual device contexts
 #
 #
 
 
-class VirtualDeviceContextTableTest(TableTestCases.StandardTableTestCase):
+class VirtualDeviceContextTableTestCase(TableTestCases.StandardTableTestCase):
     table = VirtualDeviceContextTable
     table = VirtualDeviceContextTable
 
 
 
 
@@ -203,5 +211,5 @@ class VirtualDeviceContextTableTest(TableTestCases.StandardTableTestCase):
 # MAC addresses
 # MAC addresses
 #
 #
 
 
-class MACAddressTableTest(TableTestCases.StandardTableTestCase):
+class MACAddressTableTestCase(TableTestCases.StandardTableTestCase):
     table = MACAddressTable
     table = MACAddressTable

+ 139 - 57
netbox/dcim/tests/test_views.py

@@ -622,11 +622,11 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_list_rack_elevations(self):
     def test_list_rack_elevations(self):
         """
         """
         Test viewing the list of rack elevations.
         Test viewing the list of rack elevations.
         """
         """
+        self.add_permissions('dcim.view_rack')
         response = self.client.get(reverse('dcim:rack_elevation_list'))
         response = self.client.get(reverse('dcim:rack_elevation_list'))
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
@@ -730,8 +730,8 @@ class DeviceTypeTestCase(
             'is_full_depth': False,
             'is_full_depth': False,
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_consoleports(self):
     def test_devicetype_consoleports(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_consoleporttemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         console_ports = (
         console_ports = (
             ConsolePortTemplate(device_type=devicetype, name='Console Port 1'),
             ConsolePortTemplate(device_type=devicetype, name='Console Port 1'),
@@ -743,8 +743,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_consoleports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_consoleports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_consoleserverports(self):
     def test_devicetype_consoleserverports(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_consoleserverporttemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         console_server_ports = (
         console_server_ports = (
             ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 1'),
             ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port 1'),
@@ -756,8 +756,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_consoleserverports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_consoleserverports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_powerports(self):
     def test_devicetype_powerports(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_powerporttemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         power_ports = (
         power_ports = (
             PowerPortTemplate(device_type=devicetype, name='Power Port 1'),
             PowerPortTemplate(device_type=devicetype, name='Power Port 1'),
@@ -769,8 +769,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_powerports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_powerports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_poweroutlets(self):
     def test_devicetype_poweroutlets(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_poweroutlettemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         power_outlets = (
         power_outlets = (
             PowerOutletTemplate(device_type=devicetype, name='Power Outlet 1'),
             PowerOutletTemplate(device_type=devicetype, name='Power Outlet 1'),
@@ -782,8 +782,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_poweroutlets', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_poweroutlets', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_interfaces(self):
     def test_devicetype_interfaces(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_interfacetemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         interfaces = (
         interfaces = (
             InterfaceTemplate(device_type=devicetype, name='Interface 1'),
             InterfaceTemplate(device_type=devicetype, name='Interface 1'),
@@ -795,8 +795,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_interfaces', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_interfaces', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_rearports(self):
     def test_devicetype_rearports(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_rearporttemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         rear_ports = (
         rear_ports = (
             RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
             RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
@@ -808,8 +808,12 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_rearports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_rearports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_frontports(self):
     def test_devicetype_frontports(self):
+        self.add_permissions(
+            'dcim.view_devicetype',
+            'dcim.view_frontporttemplate',
+            'dcim.view_rearporttemplate',
+        )
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         rear_ports = (
         rear_ports = (
             RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
             RearPortTemplate(device_type=devicetype, name='Rear Port 1'),
@@ -832,8 +836,8 @@ class DeviceTypeTestCase(
         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)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_modulebays(self):
     def test_devicetype_modulebays(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_modulebaytemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         module_bays = (
         module_bays = (
             ModuleBayTemplate(device_type=devicetype, name='Module Bay 1'),
             ModuleBayTemplate(device_type=devicetype, name='Module Bay 1'),
@@ -845,8 +849,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_modulebays', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_modulebays', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_devicebays(self):
     def test_devicetype_devicebays(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_devicebaytemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         device_bays = (
         device_bays = (
             DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
             DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
@@ -858,8 +862,8 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_inventoryitems(self):
     def test_devicetype_inventoryitems(self):
+        self.add_permissions('dcim.view_devicetype', 'dcim.view_inventoryitemtemplate')
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
         inventory_items = (
         inventory_items = (
             DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
             DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
@@ -872,11 +876,11 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_inventoryitems', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_inventoryitems', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_objects(self):
     def test_import_objects(self):
         """
         """
         Custom import test for YAML-based imports (versus CSV)
         Custom import test for YAML-based imports (versus CSV)
         """
         """
+        self.add_permissions('dcim.view_manufacturer', 'dcim.view_platform')
         IMPORT_DATA = """
         IMPORT_DATA = """
 manufacturer: Generic
 manufacturer: Generic
 model: TEST-1000
 model: TEST-1000
@@ -1070,12 +1074,12 @@ inventory-items:
         ii1 = InventoryItemTemplate.objects.first()
         ii1 = InventoryItemTemplate.objects.first()
         self.assertEqual(ii1.name, 'Inventory Item 1')
         self.assertEqual(ii1.name, 'Inventory Item 1')
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_error_numbering(self):
     def test_import_error_numbering(self):
         # Add all required permissions to the test user
         # Add all required permissions to the test user
         self.add_permissions(
         self.add_permissions(
             'dcim.view_devicetype',
             'dcim.view_devicetype',
             'dcim.add_devicetype',
             'dcim.add_devicetype',
+            'dcim.view_manufacturer',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_powerporttemplate',
@@ -1122,12 +1126,12 @@ module-bays:
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
         self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
         self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_nolist(self):
     def test_import_nolist(self):
         # Add all required permissions to the test user
         # Add all required permissions to the test user
         self.add_permissions(
         self.add_permissions(
             'dcim.view_devicetype',
             'dcim.view_devicetype',
             'dcim.add_devicetype',
             'dcim.add_devicetype',
+            'dcim.view_manufacturer',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_powerporttemplate',
@@ -1158,12 +1162,12 @@ console-ports: {value}
                 self.assertHttpStatus(response, 200)
                 self.assertHttpStatus(response, 200)
                 self.assertContains(response, "Record 1 console-ports: Must be a list.")
                 self.assertContains(response, "Record 1 console-ports: Must be a list.")
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_nodict(self):
     def test_import_nodict(self):
         # Add all required permissions to the test user
         # Add all required permissions to the test user
         self.add_permissions(
         self.add_permissions(
             'dcim.view_devicetype',
             'dcim.view_devicetype',
             'dcim.add_devicetype',
             'dcim.add_devicetype',
+            'dcim.view_manufacturer',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_consoleserverporttemplate',
             'dcim.add_powerporttemplate',
             'dcim.add_powerporttemplate',
@@ -1264,7 +1268,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{module_types[0].id},test model",
             f"{module_types[0].id},test model",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_bulk_update_objects_with_permission(self):
     def test_bulk_update_objects_with_permission(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
@@ -1281,7 +1284,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         super().test_bulk_update_objects_with_permission()
         super().test_bulk_update_objects_with_permission()
 
 
     @tag('regression')
     @tag('regression')
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_permission(self):
     def test_bulk_import_objects_with_permission(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
@@ -1303,7 +1305,6 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         # run base test
         # run base test
         super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
         super().test_bulk_import_objects_with_permission(post_import_callback=verify_module_type_profile)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_import_objects_with_constrained_permission(self):
     def test_bulk_import_objects_with_constrained_permission(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.add_consoleporttemplate',
             'dcim.add_consoleporttemplate',
@@ -1318,8 +1319,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
         super().test_bulk_import_objects_with_constrained_permission()
         super().test_bulk_import_objects_with_constrained_permission()
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_consoleports(self):
     def test_moduletype_consoleports(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_consoleporttemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         console_ports = (
         console_ports = (
             ConsolePortTemplate(module_type=moduletype, name='Console Port 1'),
             ConsolePortTemplate(module_type=moduletype, name='Console Port 1'),
@@ -1331,8 +1332,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_consoleports', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_consoleports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_consoleserverports(self):
     def test_moduletype_consoleserverports(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_consoleserverporttemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         console_server_ports = (
         console_server_ports = (
             ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 1'),
             ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 1'),
@@ -1344,8 +1345,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_consoleserverports', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_consoleserverports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_powerports(self):
     def test_moduletype_powerports(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_powerporttemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         power_ports = (
         power_ports = (
             PowerPortTemplate(module_type=moduletype, name='Power Port 1'),
             PowerPortTemplate(module_type=moduletype, name='Power Port 1'),
@@ -1357,8 +1358,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_powerports', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_powerports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_poweroutlets(self):
     def test_moduletype_poweroutlets(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_poweroutlettemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         power_outlets = (
         power_outlets = (
             PowerOutletTemplate(module_type=moduletype, name='Power Outlet 1'),
             PowerOutletTemplate(module_type=moduletype, name='Power Outlet 1'),
@@ -1370,8 +1371,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_poweroutlets', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_poweroutlets', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_interfaces(self):
     def test_moduletype_interfaces(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_interfacetemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         interfaces = (
         interfaces = (
             InterfaceTemplate(module_type=moduletype, name='Interface 1'),
             InterfaceTemplate(module_type=moduletype, name='Interface 1'),
@@ -1383,8 +1384,8 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_interfaces', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_interfaces', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_rearports(self):
     def test_moduletype_rearports(self):
+        self.add_permissions('dcim.view_moduletype', 'dcim.view_rearporttemplate')
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         rear_ports = (
         rear_ports = (
             RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
             RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
@@ -1396,8 +1397,12 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:moduletype_rearports', kwargs={'pk': moduletype.pk})
         url = reverse('dcim:moduletype_rearports', kwargs={'pk': moduletype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_moduletype_frontports(self):
     def test_moduletype_frontports(self):
+        self.add_permissions(
+            'dcim.view_moduletype',
+            'dcim.view_frontporttemplate',
+            'dcim.view_rearporttemplate',
+        )
         moduletype = ModuleType.objects.first()
         moduletype = ModuleType.objects.first()
         rear_ports = (
         rear_ports = (
             RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
             RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
@@ -1420,11 +1425,11 @@ class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         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)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_objects(self):
     def test_import_objects(self):
         """
         """
         Custom import test for YAML-based imports (versus CSV)
         Custom import test for YAML-based imports (versus CSV)
         """
         """
+        self.add_permissions('dcim.view_manufacturer')
         IMPORT_DATA = """
         IMPORT_DATA = """
 manufacturer: Generic
 manufacturer: Generic
 model: TEST-1000
 model: TEST-1000
@@ -2289,8 +2294,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
             'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_consoleports(self):
     def test_device_consoleports(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_consoleport')
         device = Device.objects.first()
         device = Device.objects.first()
         console_ports = (
         console_ports = (
             ConsolePort(device=device, name='Console Port 1'),
             ConsolePort(device=device, name='Console Port 1'),
@@ -2302,8 +2307,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_consoleports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_consoleports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_consoleserverports(self):
     def test_device_consoleserverports(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_consoleserverport')
         device = Device.objects.first()
         device = Device.objects.first()
         console_server_ports = (
         console_server_ports = (
             ConsoleServerPort(device=device, name='Console Server Port 1'),
             ConsoleServerPort(device=device, name='Console Server Port 1'),
@@ -2315,8 +2320,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_consoleserverports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_consoleserverports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_powerports(self):
     def test_device_powerports(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_powerport')
         device = Device.objects.first()
         device = Device.objects.first()
         power_ports = (
         power_ports = (
             PowerPort(device=device, name='Power Port 1'),
             PowerPort(device=device, name='Power Port 1'),
@@ -2328,8 +2333,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_powerports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_powerports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_poweroutlets(self):
     def test_device_poweroutlets(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_poweroutlet')
         device = Device.objects.first()
         device = Device.objects.first()
         power_outlets = (
         power_outlets = (
             PowerOutlet(device=device, name='Power Outlet 1'),
             PowerOutlet(device=device, name='Power Outlet 1'),
@@ -2341,8 +2346,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_poweroutlets', kwargs={'pk': device.pk})
         url = reverse('dcim:device_poweroutlets', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_interfaces(self):
     def test_device_interfaces(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_interface')
         device = Device.objects.first()
         device = Device.objects.first()
         interfaces = (
         interfaces = (
             Interface(device=device, name='Interface 1'),
             Interface(device=device, name='Interface 1'),
@@ -2354,8 +2359,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_interfaces', kwargs={'pk': device.pk})
         url = reverse('dcim:device_interfaces', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_rearports(self):
     def test_device_rearports(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_rearport')
         device = Device.objects.first()
         device = Device.objects.first()
         rear_ports = (
         rear_ports = (
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 1'),
@@ -2367,8 +2372,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_rearports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_rearports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_frontports(self):
     def test_device_frontports(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_frontport', 'dcim.view_rearport')
         device = Device.objects.first()
         device = Device.objects.first()
         rear_ports = (
         rear_ports = (
             RearPort(device=device, name='Rear Port 1'),
             RearPort(device=device, name='Rear Port 1'),
@@ -2391,8 +2396,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         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)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_modulebays(self):
     def test_device_modulebays(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_modulebay')
         device = Device.objects.first()
         device = Device.objects.first()
         ModuleBay.objects.create(device=device, name='Module Bay 1')
         ModuleBay.objects.create(device=device, name='Module Bay 1')
         ModuleBay.objects.create(device=device, name='Module Bay 2')
         ModuleBay.objects.create(device=device, name='Module Bay 2')
@@ -2401,8 +2406,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
         url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_devicebays(self):
     def test_device_devicebays(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_devicebay')
         device = Device.objects.first()
         device = Device.objects.first()
         device_bays = (
         device_bays = (
             DeviceBay(device=device, name='Device Bay 1'),
             DeviceBay(device=device, name='Device Bay 1'),
@@ -2414,8 +2419,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_devicebays', kwargs={'pk': device.pk})
         url = reverse('dcim:device_devicebays', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_inventory(self):
     def test_device_inventory(self):
+        self.add_permissions('dcim.view_device', 'dcim.view_inventoryitem')
         device = Device.objects.first()
         device = Device.objects.first()
         inventory_items = (
         inventory_items = (
             InventoryItem(device=device, name='Inventory Item 1'),
             InventoryItem(device=device, name='Inventory Item 1'),
@@ -2504,7 +2509,6 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertContains(response, 'background-color: #aa00bb')
         self.assertContains(response, 'background-color: #aa00bb')
         self.assertNotContains(response, 'background-color: #111111')
         self.assertNotContains(response, 'background-color: #111111')
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_bulk_import_duplicate_ids_error_message(self):
     def test_bulk_import_duplicate_ids_error_message(self):
         device = Device.objects.first()
         device = Device.objects.first()
         csv_data = (
         csv_data = (
@@ -2513,7 +2517,12 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             f"{device.pk},Device Role 2",
             f"{device.pk},Device Role 2",
         )
         )
 
 
-        self.add_permissions('dcim.add_device', 'dcim.change_device')
+        self.add_permissions(
+            'dcim.view_device',
+            'dcim.add_device',
+            'dcim.change_device',
+            'dcim.view_devicerole',
+        )
         response = self.client.post(
         response = self.client.post(
             self._get_url('bulk_import'),
             self._get_url('bulk_import'),
             {
             {
@@ -2615,15 +2624,26 @@ class ModuleTestCase(
             f"{modules[2].pk},offline,Serial 1",
             f"{modules[2].pk},offline,Serial 1",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_module_detail_includes_module_type_profile(self):
     def test_module_detail_includes_module_type_profile(self):
+        self.add_permissions(
+            'dcim.view_module',
+            'dcim.view_moduletype',
+            'dcim.view_moduletypeprofile',
+        )
         response = self.client.get(self._get_queryset().first().get_absolute_url())
         response = self.client.get(self._get_queryset().first().get_absolute_url())
 
 
         self.assertContains(response, 'Module Type Profile 1')
         self.assertContains(response, 'Module Type Profile 1')
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_module_component_replication(self):
     def test_module_component_replication(self):
-        self.add_permissions('dcim.add_module')
+        self.add_permissions(
+            'dcim.view_module',
+            'dcim.add_module',
+            'dcim.view_moduletype',
+            'dcim.view_device',
+            'dcim.view_modulebay',
+            'dcim.view_interface',
+            'extras.view_tag',
+        )
 
 
         # Add 5 InterfaceTemplates to a ModuleType
         # Add 5 InterfaceTemplates to a ModuleType
         module_type = ModuleType.objects.first()
         module_type = ModuleType.objects.first()
@@ -2654,9 +2674,15 @@ class ModuleTestCase(
         self.assertHttpStatus(self.client.post(**request), 302)
         self.assertHttpStatus(self.client.post(**request), 302)
         self.assertEqual(Interface.objects.filter(device=device).count(), 5)
         self.assertEqual(Interface.objects.filter(device=device).count(), 5)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_module_bulk_replication(self):
     def test_module_bulk_replication(self):
-        self.add_permissions('dcim.add_module')
+        self.add_permissions(
+            'dcim.view_module',
+            'dcim.add_module',
+            'dcim.view_moduletype',
+            'dcim.view_device',
+            'dcim.view_modulebay',
+            'dcim.view_interface',
+        )
 
 
         # Add 5 InterfaceTemplates to a ModuleType
         # Add 5 InterfaceTemplates to a ModuleType
         module_type = ModuleType.objects.first()
         module_type = ModuleType.objects.first()
@@ -2704,9 +2730,17 @@ class ModuleTestCase(
         self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
         self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
         self.assertEqual(Interface.objects.filter(device=device).count(), 5)
         self.assertEqual(Interface.objects.filter(device=device).count(), 5)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_module_component_adoption(self):
     def test_module_component_adoption(self):
-        self.add_permissions('dcim.add_module')
+        self.add_permissions(
+            'dcim.view_module',
+            'dcim.add_module',
+            'dcim.view_moduletype',
+            'dcim.view_device',
+            'dcim.view_modulebay',
+            'dcim.view_interface',
+            'dcim.change_interface',
+            'extras.view_tag',
+        )
 
 
         interface_name = "Interface-1"
         interface_name = "Interface-1"
 
 
@@ -2741,9 +2775,16 @@ class ModuleTestCase(
         # Check that the Interface now has a module
         # Check that the Interface now has a module
         self.assertIsNotNone(interface.module)
         self.assertIsNotNone(interface.module)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_module_bulk_adoption(self):
     def test_module_bulk_adoption(self):
-        self.add_permissions('dcim.add_module')
+        self.add_permissions(
+            'dcim.view_module',
+            'dcim.add_module',
+            'dcim.view_moduletype',
+            'dcim.view_device',
+            'dcim.view_modulebay',
+            'dcim.view_interface',
+            'dcim.change_interface',
+        )
 
 
         interface_name = "Interface-1"
         interface_name = "Interface-1"
 
 
@@ -2841,8 +2882,8 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{console_ports[2].pk},Console Port 9,New description9",
             f"{console_ports[2].pk},Console Port 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_bulk_add_components_with_changelog_message(self):
     def test_bulk_add_components_with_changelog_message(self):
+        self.add_permissions('dcim.view_consoleport', 'dcim.view_device')
         device1 = Device.objects.get(name='Device 1')
         device1 = Device.objects.get(name='Device 1')
         device2 = create_test_device('Device 2')
         device2 = create_test_device('Device 2')
         changelog_message = 'Bulk-created console ports'
         changelog_message = 'Bulk-created console ports'
@@ -2885,8 +2926,13 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         for objectchange in objectchanges:
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
             self.assertEqual(objectchange.message, changelog_message)
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_consoleport',
+            'dcim.view_consoleserverport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         consoleport = ConsolePort.objects.first()
         consoleport = ConsolePort.objects.first()
         consoleserverport = ConsoleServerPort.objects.create(
         consoleserverport = ConsoleServerPort.objects.create(
             device=consoleport.device,
             device=consoleport.device,
@@ -2950,8 +2996,13 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{console_server_ports[2].pk},Console Server Port 9,New description 9",
             f"{console_server_ports[2].pk},Console Server Port 9,New description 9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_consoleserverport',
+            'dcim.view_consoleport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         consoleserverport = ConsoleServerPort.objects.first()
         consoleserverport = ConsoleServerPort.objects.first()
         consoleport = ConsolePort.objects.create(
         consoleport = ConsolePort.objects.create(
             device=consoleserverport.device,
             device=consoleserverport.device,
@@ -3021,8 +3072,13 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{power_ports[2].pk},Power Port 9,New description9",
             f"{power_ports[2].pk},Power Port 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_powerport',
+            'dcim.view_poweroutlet',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         powerport = PowerPort.objects.first()
         powerport = PowerPort.objects.first()
         poweroutlet = PowerOutlet.objects.create(
         poweroutlet = PowerOutlet.objects.create(
             device=powerport.device,
             device=powerport.device,
@@ -3101,8 +3157,13 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{power_outlets[2].pk},Power Outlet 9,New description9",
             f"{power_outlets[2].pk},Power Outlet 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_poweroutlet',
+            'dcim.view_powerport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         poweroutlet = PowerOutlet.objects.first()
         poweroutlet = PowerOutlet.objects.first()
         powerport = PowerPort.objects.first()
         powerport = PowerPort.objects.first()
         Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save()
         Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save()
@@ -3240,8 +3301,12 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{interfaces[2].pk},Interface 9,New description9",
             f"{interfaces[2].pk},Interface 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_interface',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         interface1, interface2 = Interface.objects.all()[:2]
         interface1, interface2 = Interface.objects.all()[:2]
         Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
         Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
 
 
@@ -3387,8 +3452,14 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{front_ports[2].pk},Front Port 9,New description9",
             f"{front_ports[2].pk},Front Port 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_frontport',
+            'dcim.view_rearport',
+            'dcim.view_interface',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         frontport = FrontPort.objects.first()
         frontport = FrontPort.objects.first()
         interface = Interface.objects.create(
         interface = Interface.objects.create(
             device=frontport.device,
             device=frontport.device,
@@ -3454,8 +3525,14 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             f"{rear_ports[2].pk},Rear Port 9,New description9",
             f"{rear_ports[2].pk},Rear Port 9,New description9",
         )
         )
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_rearport',
+            'dcim.view_frontport',
+            'dcim.view_interface',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         rearport = RearPort.objects.first()
         rearport = RearPort.objects.first()
         interface = Interface.objects.create(
         interface = Interface.objects.create(
             device=rearport.device,
             device=rearport.device,
@@ -4108,8 +4185,13 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_trace(self):
     def test_trace(self):
+        self.add_permissions(
+            'dcim.view_powerfeed',
+            'dcim.view_powerport',
+            'dcim.view_cable',
+            'dcim.view_device',
+        )
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
         device_type = DeviceType.objects.create(
         device_type = DeviceType.objects.create(
             manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
             manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
@@ -4255,12 +4337,12 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
     @tag('regression')  # Issue #20542
     @tag('regression')  # Issue #20542
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
     def test_create_macaddress_via_quickadd(self):
     def test_create_macaddress_via_quickadd(self):
         """
         """
         Test creating a MAC address via quick-add modal (e.g., from Interface form).
         Test creating a MAC address via quick-add modal (e.g., from Interface form).
         Regression test for issue #20542 where form prefix was missing in POST handler.
         Regression test for issue #20542 where form prefix was missing in POST handler.
         """
         """
+        self.add_permissions('dcim.view_macaddress', 'dcim.view_interface', 'extras.view_tag')
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm = ObjectPermission(name='Test permission', actions=['add'])
         obj_perm.save()
         obj_perm.save()
         obj_perm.users.add(self.user)
         obj_perm.users.add(self.user)

+ 2 - 2
netbox/extras/api/serializers_/tableconfigs.py

@@ -1,14 +1,14 @@
 from core.models import ObjectType
 from core.models import ObjectType
 from extras.models import TableConfig
 from extras.models import TableConfig
 from netbox.api.fields import ContentTypeField
 from netbox.api.fields import ContentTypeField
-from netbox.api.serializers import ValidatedModelSerializer
+from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
 
 
 __all__ = (
 __all__ = (
     'TableConfigSerializer',
     'TableConfigSerializer',
 )
 )
 
 
 
 
-class TableConfigSerializer(ValidatedModelSerializer):
+class TableConfigSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer):
     object_type = ContentTypeField(
     object_type = ContentTypeField(
         queryset=ObjectType.objects.all()
         queryset=ObjectType.objects.all()
     )
     )

+ 43 - 5
netbox/extras/constants.py

@@ -1,3 +1,5 @@
+from jinja2 import ChainableUndefined, DebugUndefined, StrictUndefined, Undefined
+
 from core.events import *
 from core.events import *
 from extras.choices import LogLevelChoices
 from extras.choices import LogLevelChoices
 
 
@@ -32,11 +34,47 @@ WEBHOOK_EVENT_TYPES = {
     JOB_ERRORED: 'job_ended',
     JOB_ERRORED: 'job_ended',
 }
 }
 
 
-# Jinja environment parameters which support path imports
-JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
-    'undefined',
-    'finalize',
-)
+# Allowed Jinja2 environment parameters and their permitted values.
+# Only keys listed here may appear in a template's environment_params.
+#   None  = any JSON-serializable value accepted (scalars, booleans, etc.)
+#   dict  = only dict keys accepted; dict values are the resolved Python objects
+#
+# Note: 'finalize' is intentionally absent. It is deprecated and handled as a
+# legacy carve-out in RenderTemplateMixin (blocked from new use, but existing
+# stored values continue to resolve via import_string at render time).
+JINJA_ENV_PARAMS_ALLOWED = {
+    # Boolean / scalar params (accept any JSON-serializable value)
+    'auto_reload': None,
+    'autoescape': None,
+    'cache_size': None,
+    'enable_async': None,
+    'keep_trailing_newline': None,
+    'lstrip_blocks': None,
+    'optimized': None,
+    'trim_blocks': None,
+    # String params (template syntax delimiters)
+    'block_start_string': None,
+    'block_end_string': None,
+    'comment_start_string': None,
+    'comment_end_string': None,
+    'line_comment_prefix': None,
+    'line_statement_prefix': None,
+    'newline_sequence': None,
+    'variable_start_string': None,
+    'variable_end_string': None,
+    # Mapped params (value must be a key in the dict; resolved to the dict value)
+    'undefined': {
+        'jinja2.ChainableUndefined': ChainableUndefined,
+        'jinja2.DebugUndefined': DebugUndefined,
+        'jinja2.StrictUndefined': StrictUndefined,
+        'jinja2.Undefined': Undefined,
+    },
+    # Excluded (dangerous — accept callables or trigger imports):
+    #   'bytecode_cache' — accepts arbitrary object
+    #   'extensions'     — Jinja2 internally calls import_string() on string entries
+    #   'finalize'       — deprecated; legacy carve-out in RenderTemplateMixin
+    #   'loader'         — accepts arbitrary object
+}
 
 
 # Dashboard
 # Dashboard
 DEFAULT_DASHBOARD = [
 DEFAULT_DASHBOARD = [

+ 32 - 2
netbox/extras/graphql/filters.py

@@ -1,15 +1,16 @@
+from datetime import datetime
 from typing import TYPE_CHECKING, Annotated
 from typing import TYPE_CHECKING, Annotated
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 from django.db.models import Q, QuerySet
 from django.db.models import Q, QuerySet
 from strawberry.scalars import ID
 from strawberry.scalars import ID
-from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
+from strawberry_django import BaseFilterLookup, DatetimeFilterLookup, FilterLookup, StrFilterLookup
 
 
 from extras import models
 from extras import models
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from extras.graphql.filter_mixins import CustomFieldsFilterMixin, TagsFilterMixin
 from netbox.graphql.filter_mixins import SyncedDataFilterMixin
 from netbox.graphql.filter_mixins import SyncedDataFilterMixin
-from netbox.graphql.filters import ChangeLoggedModelFilter, PrimaryModelFilter
+from netbox.graphql.filters import BaseModelFilter, ChangeLoggedModelFilter, PrimaryModelFilter
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from core.graphql.filters import ContentTypeFilter
     from core.graphql.filters import ContentTypeFilter
@@ -41,8 +42,10 @@ __all__ = (
     'ExportTemplateFilter',
     'ExportTemplateFilter',
     'ImageAttachmentFilter',
     'ImageAttachmentFilter',
     'JournalEntryFilter',
     'JournalEntryFilter',
+    'NotificationFilter',
     'NotificationGroupFilter',
     'NotificationGroupFilter',
     'SavedFilterFilter',
     'SavedFilterFilter',
+    'SubscriptionFilter',
     'TableConfigFilter',
     'TableConfigFilter',
     'TagFilter',
     'TagFilter',
     'WebhookFilter',
     'WebhookFilter',
@@ -291,6 +294,21 @@ class JournalEntryFilter(CustomFieldsFilterMixin, TagsFilterMixin, ChangeLoggedM
     comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
     comments: StrFilterLookup[str] | None = strawberry_django.filter_field()
 
 
 
 
+@strawberry_django.filter_type(models.Notification, lookups=True)
+class NotificationFilter(BaseModelFilter):
+    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    read: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+    user_id: ID | None = strawberry_django.filter_field()
+    object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    object_type_id: ID | None = strawberry_django.filter_field()
+    object_id: ID | None = strawberry_django.filter_field()
+    object_repr: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    event_type: StrFilterLookup[str] | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 @strawberry_django.filter_type(models.NotificationGroup, lookups=True)
 class NotificationGroupFilter(ChangeLoggedModelFilter):
 class NotificationGroupFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     name: StrFilterLookup[str] | None = strawberry_django.filter_field()
@@ -316,6 +334,18 @@ class SavedFilterFilter(ChangeLoggedModelFilter):
     )
     )
 
 
 
 
+@strawberry_django.filter_type(models.Subscription, lookups=True)
+class SubscriptionFilter(BaseModelFilter):
+    created: DatetimeFilterLookup[datetime] | None = strawberry_django.filter_field()
+    user: Annotated['UserFilter', strawberry.lazy('users.graphql.filters')] | None = strawberry_django.filter_field()
+    user_id: ID | None = strawberry_django.filter_field()
+    object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    object_type_id: ID | None = strawberry_django.filter_field()
+    object_id: ID | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 @strawberry_django.filter_type(models.TableConfig, lookups=True)
 class TableConfigFilter(ChangeLoggedModelFilter):
 class TableConfigFilter(ChangeLoggedModelFilter):
     name: StrFilterLookup[str] | None = strawberry_django.filter_field()
     name: StrFilterLookup[str] | None = strawberry_django.filter_field()

+ 16 - 5
netbox/extras/graphql/mixins.py

@@ -4,6 +4,9 @@ import strawberry
 import strawberry_django
 import strawberry_django
 from strawberry.types import Info
 from strawberry.types import Info
 
 
+from extras.models import ImageAttachment, JournalEntry
+from utilities.querysets import RestrictedPrefetch
+
 __all__ = (
 __all__ = (
     'ConfigContextMixin',
     'ConfigContextMixin',
     'ContactsMixin',
     'ContactsMixin',
@@ -50,16 +53,24 @@ class CustomFieldsMixin:
 @strawberry.type
 @strawberry.type
 class ImageAttachmentsMixin:
 class ImageAttachmentsMixin:
 
 
-    @strawberry_django.field
-    def image_attachments(self, info: Info) -> list[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
-        return self.images.restrict(info.context.request.user, 'view')
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'images', info.context.request.user, 'view', queryset=ImageAttachment.objects.all()
+        ),
+    )
+    def image_attachments(self) -> list[Annotated['ImageAttachmentType', strawberry.lazy('.types')]]:
+        return self.images.all()
 
 
 
 
 @strawberry.type
 @strawberry.type
 class JournalEntriesMixin:
 class JournalEntriesMixin:
 
 
-    @strawberry_django.field
-    def journal_entries(self, info: Info) -> list[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
+    @strawberry_django.field(
+        prefetch_related=lambda info: RestrictedPrefetch(
+            'journal_entries', info.context.request.user, 'view', queryset=JournalEntry.objects.all()
+        ),
+    )
+    def journal_entries(self) -> list[Annotated['JournalEntryType', strawberry.lazy('.types')]]:
         return self.journal_entries.all()
         return self.journal_entries.all()
 
 
 
 

+ 3 - 2
netbox/extras/graphql/types.py

@@ -161,7 +161,7 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.Notification,
     models.Notification,
-    # filters=NotificationFilter
+    filters=NotificationFilter,
     pagination=True
     pagination=True
 )
 )
 class NotificationType(ObjectType):
 class NotificationType(ObjectType):
@@ -190,7 +190,7 @@ class SavedFilterType(OwnerMixin, ObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.Subscription,
     models.Subscription,
-    # filters=NotificationFilter
+    filters=SubscriptionFilter,
     pagination=True
     pagination=True
 )
 )
 class SubscriptionType(ObjectType):
 class SubscriptionType(ObjectType):
@@ -204,6 +204,7 @@ class SubscriptionType(ObjectType):
     pagination=True
     pagination=True
 )
 )
 class TableConfigType(ObjectType):
 class TableConfigType(ObjectType):
+    object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
     user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
 
 
 
 

+ 1 - 1
netbox/extras/management/commands/runscript.py

@@ -68,7 +68,7 @@ class Command(BaseCommand):
                 'info': logging.INFO,
                 'info': logging.INFO,
                 'warning': logging.WARNING,
                 'warning': logging.WARNING,
             }[loglevel])
             }[loglevel])
-        except KeyError:
+        except KeyError:  # pragma: no cover
             raise CommandError(f"Invalid log level: {loglevel}")
             raise CommandError(f"Invalid log level: {loglevel}")
 
 
         # Initialize the script form
         # Initialize the script form

+ 77 - 6
netbox/extras/models/mixins.py

@@ -4,6 +4,7 @@ import os
 import sys
 import sys
 from collections import defaultdict
 from collections import defaultdict
 
 
+from django.core.exceptions import ValidationError
 from django.core.files.storage import storages
 from django.core.files.storage import storages
 from django.db import models
 from django.db import models
 from django.http import HttpResponse
 from django.http import HttpResponse
@@ -11,7 +12,7 @@ from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
-from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT
+from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED
 from extras.utils import filename_from_model, filename_from_object
 from extras.utils import filename_from_model, filename_from_object
 from utilities.jinja2 import render_jinja2
 from utilities.jinja2 import render_jinja2
 
 
@@ -134,14 +135,84 @@ class RenderTemplateMixin(models.Model):
 
 
         return _context
         return _context
 
 
-    def get_environment_params(self):
+    def clean(self):
+        super().clean()
+
+        params = self.environment_params or {}
+        for key, value in params.items():
+            # finalize is deprecated: block new use but preserve existing stored values
+            if key == 'finalize':
+                raise ValidationError({
+                    'environment_params': _(
+                        'The "{key}" parameter is deprecated and may not be set on new or modified templates.'
+                    ).format(key=key)
+                })
+            if key not in JINJA_ENV_PARAMS_ALLOWED:
+                raise ValidationError({
+                    'environment_params': _(
+                        '"{key}" is not a permitted Jinja2 environment parameter.'
+                    ).format(key=key)
+                })
+            allowed = JINJA_ENV_PARAMS_ALLOWED[key]
+            if type(allowed) is dict:
+                if value not in allowed:
+                    raise ValidationError({
+                        'environment_params': _(
+                            'Invalid value "{value}" for parameter "{key}". '
+                            'Allowed values are: {allowed}'
+                        ).format(
+                            value=value,
+                            key=key,
+                            allowed=', '.join(sorted(allowed.keys()))
+                        )
+                    })
+
+    @staticmethod
+    def _filter_environment_params(params):
         """
         """
-        Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution).
+        Return a copy of params with only permitted keys. Keys not in the allowlist are
+        stripped, except 'finalize' which is a deprecated legacy carve-out.
         """
         """
-        params = self.environment_params or {}
+        return {
+            key: value for key, value in params.items()
+            if key in JINJA_ENV_PARAMS_ALLOWED or key == 'finalize'
+        }
+
+    @staticmethod
+    def _resolve_mapped_params(params):
+        """
+        Resolve allowlisted params that have a value mapping (e.g. undefined class names)
+        to their Python objects via direct dict lookup. Returns a new dict with resolved values;
+        unresolved params are passed through unchanged.
+        """
+        resolved = {}
         for name, value in params.items():
         for name, value in params.items():
-            if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
-                params[name] = import_string(value)
+            allowed = JINJA_ENV_PARAMS_ALLOWED.get(name)
+            if type(allowed) is dict and value in allowed:
+                resolved[name] = allowed[value]
+            else:
+                resolved[name] = value
+        return resolved
+
+    @staticmethod
+    def _resolve_finalize(params):
+        """
+        Legacy carve-out: resolve the deprecated 'finalize' parameter via import_string().
+        Existing templates with finalize continue to work; new use is blocked by clean().
+        """
+        if 'finalize' in params and type(params['finalize']) is str:
+            return {**params, 'finalize': import_string(params['finalize'])}
+        return params
+
+    def get_environment_params(self):
+        """
+        Pre-processing of any defined Jinja environment parameters.
+        """
+        # Shallow-copy so resolved values don't replace the strings on the model field.
+        params = dict(self.environment_params or {})
+        params = self._filter_environment_params(params)
+        params = self._resolve_mapped_params(params)
+        params = self._resolve_finalize(params)
         return params
         return params
 
 
     def render(self, context=None, queryset=None):
     def render(self, context=None, queryset=None):

+ 1 - 1
netbox/extras/models/models.py

@@ -658,7 +658,7 @@ class TableConfig(CloningMixin, ChangeLoggedModel):
         table = self.table_class([])
         table = self.table_class([])
 
 
         # Validate ordering columns
         # Validate ordering columns
-        for name in self.ordering:
+        for name in self.ordering or []:
             if name.startswith('-'):
             if name.startswith('-'):
                 name = name[1:]  # Strip leading hyphen
                 name = name[1:]  # Strip leading hyphen
             if name not in table.columns:
             if name not in table.columns:

+ 28 - 22
netbox/extras/tests/test_api.py

@@ -22,7 +22,7 @@ from users.models import Group, Token, User
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 
 
-class AppTest(APITestCase):
+class AppTestCase(APITestCase):
 
 
     def test_root(self):
     def test_root(self):
 
 
@@ -32,7 +32,7 @@ class AppTest(APITestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class WebhookTest(APIViewTestCases.APIViewTestCase):
+class WebhookTestCase(APIViewTestCases.APIViewTestCase):
     model = Webhook
     model = Webhook
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -74,7 +74,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
         Webhook.objects.bulk_create(webhooks)
         Webhook.objects.bulk_create(webhooks)
 
 
 
 
-class EventRuleTest(APIViewTestCases.APIViewTestCase):
+class EventRuleTestCase(APIViewTestCases.APIViewTestCase):
     model = EventRule
     model = EventRule
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -152,7 +152,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class CustomFieldTest(APIViewTestCases.APIViewTestCase):
+class CustomFieldTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomField
     model = CustomField
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -204,7 +204,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
             cf.object_types.add(site_ct)
             cf.object_types.add(site_ct)
 
 
 
 
-class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
+class CustomFieldChoiceSetTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomFieldChoiceSet
     model = CustomFieldChoiceSet
     brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
     brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -325,7 +325,7 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
 
 
 
 
-class CustomLinkTest(APIViewTestCases.APIViewTestCase):
+class CustomLinkTestCase(APIViewTestCases.APIViewTestCase):
     model = CustomLink
     model = CustomLink
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -385,7 +385,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
             custom_link.object_types.set([site_type])
             custom_link.object_types.set([site_type])
 
 
 
 
-class SavedFilterTest(APIViewTestCases.APIViewTestCase):
+class SavedFilterTestCase(APIViewTestCases.APIViewTestCase):
     model = SavedFilter
     model = SavedFilter
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
@@ -458,7 +458,7 @@ class SavedFilterTest(APIViewTestCases.APIViewTestCase):
             savedfilter.object_types.set([site_type])
             savedfilter.object_types.set([site_type])
 
 
 
 
-class BookmarkTest(
+class BookmarkTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.CreateObjectViewTestCase,
     APIViewTestCases.CreateObjectViewTestCase,
@@ -510,7 +510,7 @@ class BookmarkTest(
         ]
         ]
 
 
 
 
-class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
+class ExportTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
     model = ExportTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -560,7 +560,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
             et.object_types.set([device_object_type])
             et.object_types.set([device_object_type])
 
 
 
 
-class TagTest(APIViewTestCases.APIViewTestCase):
+class TagTestCase(APIViewTestCases.APIViewTestCase):
     model = Tag
     model = Tag
     brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
     brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
     create_data = [
@@ -593,7 +593,7 @@ class TagTest(APIViewTestCases.APIViewTestCase):
         Tag.objects.bulk_create(tags)
         Tag.objects.bulk_create(tags)
 
 
 
 
-class TaggedItemTest(
+class TaggedItemTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase
     APIViewTestCases.ListObjectsViewTestCase
 ):
 ):
@@ -622,7 +622,7 @@ class TaggedItemTest(
 
 
 
 
 # TODO: Standardize to APIViewTestCase (needs create & update tests)
 # TODO: Standardize to APIViewTestCase (needs create & update tests)
-class ImageAttachmentTest(
+class ImageAttachmentTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.DeleteObjectViewTestCase,
     APIViewTestCases.DeleteObjectViewTestCase,
@@ -666,7 +666,7 @@ class ImageAttachmentTest(
         ImageAttachment.objects.bulk_create(image_attachments)
         ImageAttachment.objects.bulk_create(image_attachments)
 
 
 
 
-class JournalEntryTest(APIViewTestCases.APIViewTestCase):
+class JournalEntryTestCase(APIViewTestCases.APIViewTestCase):
     model = JournalEntry
     model = JournalEntry
     brief_fields = ['created', 'display', 'id', 'url']
     brief_fields = ['created', 'display', 'id', 'url']
     bulk_update_data = {
     bulk_update_data = {
@@ -716,7 +716,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
+class ConfigContextProfileTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigContextProfile
     model = ConfigContextProfile
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -825,7 +825,7 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.data['data_file']['id'], datafile.pk)
         self.assertEqual(response.data['data_file']['id'], datafile.pk)
 
 
 
 
-class ConfigContextTest(APIViewTestCases.APIViewTestCase):
+class ConfigContextTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
     model = ConfigContext
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -951,7 +951,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
         self.assertEqual(response.data['data_file']['id'], datafile.pk)
         self.assertEqual(response.data['data_file']['id'], datafile.pk)
 
 
 
 
-class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
+class ConfigTemplateTestCase(APIViewTestCases.APIViewTestCase):
     model = ConfigTemplate
     model = ConfigTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -1036,7 +1036,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
 
 
-class ScriptTest(APITestCase):
+class ScriptTestCase(APITestCase):
 
 
     class TestScriptClass(PythonClass):
     class TestScriptClass(PythonClass):
         class Meta:
         class Meta:
@@ -1162,7 +1162,7 @@ class ScriptTest(APITestCase):
             self.TestScriptClass.Meta.scheduling_enabled = original
             self.TestScriptClass.Meta.scheduling_enabled = original
 
 
 
 
-class CreatedUpdatedFilterTest(APITestCase):
+class CreatedUpdatedFilterTestCase(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1236,9 +1236,12 @@ class CreatedUpdatedFilterTest(APITestCase):
         self.assertEqual(response.data['results'][0]['id'], rack2.pk)
         self.assertEqual(response.data['results'][0]['id'], rack2.pk)
 
 
 
 
-class SubscriptionTest(APIViewTestCases.APIViewTestCase):
+class SubscriptionTestCase(APIViewTestCases.APIViewTestCase):
     model = Subscription
     model = Subscription
     brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
     brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
+    graphql_filter = {
+        'id': {'lookup': 'gt', 'value': '0'},
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1295,7 +1298,7 @@ class SubscriptionTest(APIViewTestCases.APIViewTestCase):
         }
         }
 
 
 
 
-class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
+class NotificationGroupTestCase(APIViewTestCases.APIViewTestCase):
     model = NotificationGroup
     model = NotificationGroup
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
     create_data = [
@@ -1372,12 +1375,15 @@ class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class NotificationTest(APIViewTestCases.APIViewTestCase):
+class NotificationTestCase(APIViewTestCases.APIViewTestCase):
     model = Notification
     model = Notification
     brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
     brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
     bulk_update_data = {
     bulk_update_data = {
         'read': now(),
         'read': now(),
     }
     }
+    graphql_filter = {
+        'event_type': {'lookup': 'exact', 'value': OBJECT_CREATED},
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1436,7 +1442,7 @@ class NotificationTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class ScriptModuleTest(APITestCase):
+class ScriptModuleTestCase(APITestCase):
     """
     """
     Tests for the POST /api/extras/scripts/upload/ endpoint.
     Tests for the POST /api/extras/scripts/upload/ endpoint.
 
 

+ 1 - 1
netbox/extras/tests/test_conditions.py

@@ -134,7 +134,7 @@ class ConditionTestCase(TestCase):
         self.assertTrue(c.eval({'x': '123'}))
         self.assertTrue(c.eval({'x': '123'}))
 
 
 
 
-class ConditionSetTest(TestCase):
+class ConditionSetTestCase(TestCase):
 
 
     def test_empty(self):
     def test_empty(self):
         with self.assertRaises(ValueError):
         with self.assertRaises(ValueError):

+ 4 - 4
netbox/extras/tests/test_custom_validation.py

@@ -8,7 +8,7 @@ from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
 from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
 
 
 
 
-class ModelFormCustomValidationTest(TestCase):
+class ModelFormCustomValidationTestCase(TestCase):
 
 
     @override_settings(CUSTOM_VALIDATORS={
     @override_settings(CUSTOM_VALIDATORS={
         'circuits.provider': [
         'circuits.provider': [
@@ -58,7 +58,7 @@ class ModelFormCustomValidationTest(TestCase):
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
 
 
 
 
-class BulkEditCustomValidationTest(ModelViewTestCase):
+class BulkEditCustomValidationTestCase(ModelViewTestCase):
     model = Provider
     model = Provider
 
 
     @classmethod
     @classmethod
@@ -155,7 +155,7 @@ class BulkEditCustomValidationTest(ModelViewTestCase):
             self.assertTrue(provider.asns.exists())
             self.assertTrue(provider.asns.exists())
 
 
 
 
-class BulkImportCustomValidationTest(ModelViewTestCase):
+class BulkImportCustomValidationTestCase(ModelViewTestCase):
     model = Provider
     model = Provider
 
 
     @classmethod
     @classmethod
@@ -214,7 +214,7 @@ class BulkImportCustomValidationTest(ModelViewTestCase):
         self.assertTrue(Provider.objects.exists())
         self.assertTrue(Provider.objects.exists())
 
 
 
 
-class APISerializerCustomValidationTest(APITestCase):
+class APISerializerCustomValidationTestCase(APITestCase):
 
 
     @override_settings(CUSTOM_VALIDATORS={
     @override_settings(CUSTOM_VALIDATORS={
         'circuits.provider': [
         'circuits.provider': [

+ 6 - 6
netbox/extras/tests/test_customfields.py

@@ -19,7 +19,7 @@ from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 
 
 
 
-class CustomFieldTest(TestCase):
+class CustomFieldTestCase(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -762,7 +762,7 @@ class CustomFieldTest(TestCase):
             ).full_clean()
             ).full_clean()
 
 
 
 
-class CustomFieldManagerTest(TestCase):
+class CustomFieldManagerTestCase(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -776,7 +776,7 @@ class CustomFieldManagerTest(TestCase):
         self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
         self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
 
 
 
 
-class CustomFieldAPITest(APITestCase):
+class CustomFieldAPITestCase(APITestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1564,7 +1564,7 @@ class CustomFieldAPITest(APITestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
 
 
-class CustomFieldImportTest(TestCase):
+class CustomFieldImportTestCase(TestCase):
     user_permissions = (
     user_permissions = (
         'dcim.view_site',
         'dcim.view_site',
         'dcim.add_site',
         'dcim.add_site',
@@ -1694,7 +1694,7 @@ class CustomFieldImportTest(TestCase):
         self.assertIn('cf_select', form.errors)
         self.assertIn('cf_select', form.errors)
 
 
 
 
-class CustomFieldModelTest(TestCase):
+class CustomFieldModelTestCase(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1755,7 +1755,7 @@ class CustomFieldModelTest(TestCase):
         site.clean()
         site.clean()
 
 
 
 
-class CustomFieldModelFilterTest(TestCase):
+class CustomFieldModelFilterTestCase(TestCase):
     queryset = Site.objects.all()
     queryset = Site.objects.all()
     filterset = SiteFilterSet
     filterset = SiteFilterSet
 
 

+ 3 - 3
netbox/extras/tests/test_customvalidators.py

@@ -98,7 +98,7 @@ request_validator = CustomValidator({
 custom_validator = MyValidator()
 custom_validator = MyValidator()
 
 
 
 
-class CustomValidatorTest(TestCase):
+class CustomValidatorTestCase(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -207,7 +207,7 @@ class CustomValidatorTest(TestCase):
         request_validator(site, request)
         request_validator(site, request)
 
 
 
 
-class CustomValidatorConfigTest(TestCase):
+class CustomValidatorConfigTestCase(TestCase):
 
 
     @override_settings(
     @override_settings(
         CUSTOM_VALIDATORS={
         CUSTOM_VALIDATORS={
@@ -242,7 +242,7 @@ class CustomValidatorConfigTest(TestCase):
             Site(name='bar', slug='bar').clean()
             Site(name='bar', slug='bar').clean()
 
 
 
 
-class ProtectionRulesConfigTest(TestCase):
+class ProtectionRulesConfigTestCase(TestCase):
 
 
     @override_settings(
     @override_settings(
         PROTECTION_RULES={
         PROTECTION_RULES={

+ 1 - 1
netbox/extras/tests/test_dashboard.py

@@ -3,7 +3,7 @@ from django.test import TestCase, tag
 from extras.dashboard.widgets import ObjectListWidget
 from extras.dashboard.widgets import ObjectListWidget
 
 
 
 
-class ObjectListWidgetTests(TestCase):
+class ObjectListWidgetTestCase(TestCase):
     def test_widget_config_form_validates_model(self):
     def test_widget_config_form_validates_model(self):
         model_info = 'extras.notification'
         model_info = 'extras.notification'
         form = ObjectListWidget.ConfigForm({'model': model_info})
         form = ObjectListWidget.ConfigForm({'model': model_info})

+ 1 - 1
netbox/extras/tests/test_event_rules.py

@@ -24,7 +24,7 @@ from netbox.context_managers import event_tracking
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
 
 
 
 
-class EventRuleTest(APITestCase):
+class EventRuleTestCase(APITestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()

+ 3 - 3
netbox/extras/tests/test_forms.py

@@ -9,7 +9,7 @@ from extras.forms.model_forms import CustomFieldChoiceSetForm
 from extras.models import CustomField, CustomFieldChoiceSet
 from extras.models import CustomField, CustomFieldChoiceSet
 
 
 
 
-class CustomFieldModelFormTest(TestCase):
+class CustomFieldModelFormTestCase(TestCase):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -91,7 +91,7 @@ class CustomFieldModelFormTest(TestCase):
             self.assertIsNone(instance.custom_field_data[field_type])
             self.assertIsNone(instance.custom_field_data[field_type])
 
 
 
 
-class CustomFieldChoiceSetFormTest(TestCase):
+class CustomFieldChoiceSetFormTestCase(TestCase):
 
 
     def test_escaped_colons_preserved_on_edit(self):
     def test_escaped_colons_preserved_on_edit(self):
         choice_set = CustomFieldChoiceSet.objects.create(
         choice_set = CustomFieldChoiceSet.objects.create(
@@ -142,7 +142,7 @@ class CustomFieldChoiceSetFormTest(TestCase):
         self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
         self.assertEqual(updated.choice_colors, {'choice2': 'green', 'foo:bar': 'red'})
 
 
 
 
-class SavedFilterFormTest(TestCase):
+class SavedFilterFormTestCase(TestCase):
 
 
     def test_basic_submit(self):
     def test_basic_submit(self):
         """
         """

+ 329 - 0
netbox/extras/tests/test_jobs.py

@@ -0,0 +1,329 @@
+from contextlib import contextmanager
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.db import DEFAULT_DB_ALIAS
+from django.test import TestCase
+
+from extras.jobs import ScriptJob
+from utilities.exceptions import AbortScript
+
+
+def _make_runner(**job_attrs):
+    """
+    Build a ScriptJob without going through ``__init__``.
+
+    ``JobRunner.__init__`` attaches a ``JobLogHandler`` to a module-level
+    singleton logger; instantiating one per test would accumulate handlers.
+    """
+    runner = ScriptJob.__new__(ScriptJob)
+    runner.job = MagicMock(**job_attrs)
+    runner.logger = MagicMock()
+    return runner
+
+
+class DummyScript:
+    """A minimal stand-in for a Script implementation."""
+
+    full_name = 'tests.DummyScript'
+
+    def __init__(self, run_result=None, run_exception=None, failed=False):
+        self._run_result = run_result
+        self._run_exception = run_exception
+        self.failed = failed
+        self.output = None
+        self.request = None
+        self.run = MagicMock(side_effect=self._run_impl)
+        self.log_info = MagicMock()
+        self.log_failure = MagicMock()
+        self.get_job_data = MagicMock(return_value={'status': 'done'})
+
+    def _run_impl(self, data, commit):
+        if self._run_exception is not None:
+            raise self._run_exception
+        return self._run_result
+
+
+class RunScriptTestCase(TestCase):
+    def test_run_script_success_commit_true_sets_output_and_job_data(self):
+        runner = _make_runner()
+        script = DummyScript(run_result='hello')
+
+        runner.run_script(script, request=None, data={'k': 'v'}, commit=True)
+
+        self.assertEqual(script.output, 'hello')
+        script.run.assert_called_once_with({'k': 'v'}, True)
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_commit_false_rolls_back_without_raising(self):
+        runner = _make_runner()
+        script = DummyScript(run_result='hello')
+
+        runner.run_script(script, request=None, data={}, commit=False)
+
+        script.run.assert_called_once_with({}, False)
+        # Rollback path: log_info() is called (with the translated revert message);
+        # job.data is still populated. Don't assert exact wording.
+        script.log_info.assert_called_once()
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_commit_false_logs_warning_when_script_failed(self):
+        runner = _make_runner()
+        script = DummyScript(run_result='hello', failed=True)
+
+        with patch('extras.jobs.logging.getLogger') as get_logger:
+            logger = get_logger.return_value
+            runner.run_script(script, request=None, data={}, commit=False)
+
+        logger.warning.assert_any_call('Script failed')
+
+    def test_run_script_abort_script_logs_failure_and_reraises(self):
+        # Non-report scripts call `script.log_failure(msg)` positionally; report-style
+        # scripts use `log_failure(message=msg)`. See `test_run_script_abort_script_uses_report_log_failure_signature`.
+        runner = _make_runner()
+        script = DummyScript(run_exception=AbortScript('nope'))
+
+        with self.assertRaises(AbortScript):
+            runner.run_script(script, request=None, data={}, commit=True)
+
+        # Outcome assertions: AbortScript re-raised, failure logged on the script,
+        # rollback path traversed (log_info called), job.data populated.
+        script.log_failure.assert_called_once()
+        # Non-report path uses the positional signature; verify both that there is
+        # a positional arg and no `message=` kwarg.
+        self.assertTrue(script.log_failure.call_args.args)
+        self.assertFalse(script.log_failure.call_args.kwargs)
+        script.log_info.assert_called_once()
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_abort_script_uses_report_log_failure_signature(self):
+        runner = _make_runner()
+        script = DummyScript(run_exception=AbortScript('reportfail'))
+
+        with (
+            patch('extras.jobs.is_report', return_value=True),
+            self.assertRaises(AbortScript),
+        ):
+            runner.run_script(script, request=None, data={}, commit=True)
+
+        # For reports, log_failure is called with the keyword-form signature.
+        script.log_failure.assert_called_once()
+        self.assertIn('message', script.log_failure.call_args.kwargs)
+
+    def test_run_script_general_exception_logs_traceback_and_reraises(self):
+        runner = _make_runner()
+        script = DummyScript(run_exception=RuntimeError('boom'))
+
+        with self.assertRaisesMessage(RuntimeError, 'boom'):
+            runner.run_script(script, request=None, data={}, commit=True)
+
+        script.log_failure.assert_called_once()
+        # The failure message wraps a traceback; assert on the structural marker
+        # (Traceback header) rather than translated copy.
+        message = script.log_failure.call_args.kwargs['message']
+        self.assertIn('Traceback', message)
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_sends_clear_events_when_request_is_present_and_error_occurs(self):
+        runner = _make_runner()
+        script = DummyScript(run_exception=RuntimeError('boom'))
+        request = MagicMock()
+
+        with patch('extras.jobs.clear_events.send') as send:
+            with self.assertRaises(RuntimeError):
+                runner.run_script(script, request=request, data={}, commit=True)
+
+        send.assert_called_once_with(request)
+
+    def test_run_script_default_db_uses_single_atomic(self):
+        # Symmetric with test_run_script_enters_secondary_atomic_when_changelog_db_differs:
+        # when the changelog DB is the default, only a single atomic() block is opened.
+        runner = _make_runner()
+        script = DummyScript(run_result='ok')
+        atomic_calls = []
+
+        @contextmanager
+        def fake_atomic(using):
+            atomic_calls.append(using)
+            yield
+
+        with (
+            patch('extras.jobs.router.db_for_write', return_value=DEFAULT_DB_ALIAS),
+            patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
+        ):
+            runner.run_script(script, request=None, data={}, commit=True)
+
+        self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS])
+        script.run.assert_called_once_with({}, True)
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_enters_secondary_atomic_when_changelog_db_differs(self):
+        # Verifies that the code enters two nested atomic() context managers when
+        # the changelog DB differs from the default. Does not exercise real rollback
+        # semantics (transaction.atomic is patched).
+        runner = _make_runner()
+        script = DummyScript(run_result='ok')
+
+        atomic_calls = []
+
+        @contextmanager
+        def fake_atomic(using):
+            atomic_calls.append(using)
+            yield
+
+        with (
+            patch('extras.jobs.router.db_for_write', return_value='changelog_db'),
+            patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
+        ):
+            runner.run_script(script, request=None, data={}, commit=True)
+
+        self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS, 'changelog_db'])
+        script.run.assert_called_once_with({}, True)
+        self.assertEqual(runner.job.data, {'status': 'done'})
+
+    def test_run_script_enters_secondary_atomic_when_commit_false(self):
+        # Mirror of the previous test for the commit=False branch on the secondary
+        # DB path. Verifies both atomic blocks are entered; does not exercise true
+        # rollback semantics (transaction.atomic is patched).
+        runner = _make_runner()
+        script = DummyScript(run_result='ok')
+        atomic_calls = []
+
+        @contextmanager
+        def fake_atomic(using):
+            atomic_calls.append(using)
+            yield
+
+        with (
+            patch('extras.jobs.router.db_for_write', return_value='changelog_db'),
+            patch('extras.jobs.transaction.atomic', side_effect=fake_atomic),
+        ):
+            runner.run_script(script, request=None, data={}, commit=False)
+
+        self.assertEqual(atomic_calls, [DEFAULT_DB_ALIAS, 'changelog_db'])
+        script.run.assert_called_once_with({}, False)
+        script.log_info.assert_called_once()
+
+
+class ScriptJobRunTestCase(TestCase):
+    @staticmethod
+    def _runner():
+        return _make_runner(object_id=1)
+
+    @staticmethod
+    def _script_model(script_instance):
+        return SimpleNamespace(pk=1, python_class=MagicMock(return_value=script_instance))
+
+    def test_run_loads_script_model_and_instantiates_python_class(self):
+        runner = self._runner()
+        script_instance = DummyScript()
+        script_model = self._script_model(script_instance)
+
+        with (
+            patch('extras.jobs.ScriptModel.objects.get', return_value=script_model) as get_script_model,
+            patch.object(ScriptJob, 'run_script') as run_script,
+            patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
+        ):
+            runner.run(data={'k': 'v'}, commit=True)
+
+        get_script_model.assert_called_once_with(pk=1)
+        script_model.python_class.assert_called_once_with()
+        run_script.assert_called_once()
+        args, _ = run_script.call_args
+        self.assertIs(args[0], script_instance)
+        self.assertEqual(args[2], {'k': 'v'})  # data
+        self.assertIs(args[3], True)  # commit
+
+    def test_run_merges_request_files_into_data(self):
+        runner = self._runner()
+        script_instance = DummyScript()
+        script_model = self._script_model(script_instance)
+        request = MagicMock(FILES={'upload': 'fileobj'}, id='req-1')
+        data = {'k': 'v'}
+
+        with (
+            patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
+            patch.object(ScriptJob, 'run_script') as run_script,
+            patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
+        ):
+            runner.run(data=data, request=request, commit=True)
+
+        passed_data = run_script.call_args.args[2]
+        self.assertEqual(passed_data['k'], 'v')
+        self.assertEqual(passed_data['upload'], 'fileobj')
+
+    def test_run_sets_script_request_when_request_is_present(self):
+        runner = self._runner()
+        script_instance = DummyScript()
+        script_model = self._script_model(script_instance)
+        request = MagicMock(FILES={}, id='req-1')
+
+        with (
+            patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
+            patch.object(ScriptJob, 'run_script'),
+            patch.dict('netbox.registry.registry', {'request_processors': []}, clear=False),
+        ):
+            runner.run(data={}, request=request, commit=True)
+
+        self.assertIs(script_instance.request, request)
+
+    def _processor_factories(self, entered):
+        def ctx_a(request):
+            @contextmanager
+            def _ctx():
+                entered.append('proc_a')
+                yield
+
+            return _ctx()
+
+        def ctx_event(request):
+            @contextmanager
+            def _ctx():
+                entered.append('event_tracking')
+                yield
+
+            return _ctx()
+
+        return ctx_a, ctx_event
+
+    def test_run_uses_request_processors_when_commit_true(self):
+        runner = self._runner()
+        script_instance = DummyScript()
+        script_model = self._script_model(script_instance)
+        entered = []
+        ctx_a, ctx_event = self._processor_factories(entered)
+
+        with (
+            patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
+            patch.object(ScriptJob, 'run_script'),
+            patch('extras.jobs.event_tracking', new=ctx_event),
+            patch.dict(
+                'netbox.registry.registry',
+                {'request_processors': [ctx_a, ctx_event]},
+                clear=False,
+            ),
+        ):
+            runner.run(data={}, commit=True)
+
+        self.assertEqual(entered, ['proc_a', 'event_tracking'])
+
+    def test_run_skips_event_tracking_when_commit_false(self):
+        runner = self._runner()
+        script_instance = DummyScript()
+        script_model = self._script_model(script_instance)
+        entered = []
+        ctx_a, ctx_event = self._processor_factories(entered)
+
+        with (
+            patch('extras.jobs.ScriptModel.objects.get', return_value=script_model),
+            patch.object(ScriptJob, 'run_script'),
+            patch('extras.jobs.event_tracking', new=ctx_event),
+            patch.dict(
+                'netbox.registry.registry',
+                {'request_processors': [ctx_a, ctx_event]},
+                clear=False,
+            ),
+        ):
+            runner.run(data={}, commit=False)
+
+        self.assertEqual(entered, ['proc_a'])

+ 504 - 0
netbox/extras/tests/test_management_commands.py

@@ -0,0 +1,504 @@
+from io import BytesIO, StringIO
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.test import TestCase
+
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
+from extras.management.commands import renaturalize, webhook_receiver
+from extras.management.commands.webhook_receiver import WebhookHandler
+from users.models import User
+from utilities.fields import NaturalOrderingField
+
+
+class ReindexTestCase(TestCase):
+    def test_reindex_all_registered_indexers(self):
+        class DummyObjects:
+            @staticmethod
+            def iterator():
+                return iter(())
+
+        class DummyModel:
+            objects = DummyObjects()
+            _meta = SimpleNamespace(app_label='extras', model_name='dummy')
+
+        indexer = SimpleNamespace(model=DummyModel)
+        out = StringIO()
+
+        with (
+            patch('extras.management.commands.reindex.registry', {'search': {'extras.dummy': indexer}}),
+            patch('extras.management.commands.reindex.search_backend') as search_backend,
+        ):
+            search_backend.clear.return_value = 0
+            search_backend.cache.return_value = 0
+            search_backend.size = 0
+
+            call_command('reindex', stdout=out)
+
+        search_backend.clear.assert_called_once_with(object_types=None)
+        search_backend.cache.assert_called_once()
+        self.assertIn('Completed.', out.getvalue())
+
+    def test_reindex_lazy_skips_models_with_existing_cache(self):
+        class DummyObjects:
+            @staticmethod
+            def iterator():
+                return iter(())
+
+        class DummyModel:
+            objects = DummyObjects()
+            _meta = SimpleNamespace(app_label='extras', model_name='dummy')
+
+        content_type = object()
+        indexer = SimpleNamespace(model=DummyModel)
+        out = StringIO()
+
+        with (
+            patch('extras.management.commands.reindex.registry', {'search': {'extras.dummy': indexer}}),
+            patch('extras.management.commands.reindex.search_backend') as search_backend,
+            patch.object(ContentType.objects, 'get_for_model', return_value=content_type),
+        ):
+            search_backend.count.return_value = 1
+            search_backend.size = 1
+
+            call_command('reindex', lazy=True, stdout=out)
+
+        search_backend.clear.assert_not_called()
+        search_backend.count.assert_called_once_with(object_types=[content_type])
+        search_backend.cache.assert_not_called()
+        self.assertIn('Skipping', out.getvalue())
+
+    def test_reindex_specific_model_caches_objects_and_reports_total_count(self):
+        iterator = iter([object()])
+
+        class DummyObjects:
+            @staticmethod
+            def iterator():
+                return iterator
+
+        class DummyModel:
+            objects = DummyObjects()
+            _meta = SimpleNamespace(app_label='extras', model_name='dummy')
+
+        content_type = object()
+        indexer = SimpleNamespace(model=DummyModel)
+        out = StringIO()
+
+        with (
+            patch('extras.management.commands.reindex.registry', {'search': {'extras.dummy': indexer}}),
+            patch('extras.management.commands.reindex.search_backend') as search_backend,
+            patch.object(ContentType.objects, 'get_for_model', return_value=content_type),
+        ):
+            search_backend.clear.return_value = 2
+            search_backend.cache.return_value = 1
+            search_backend.size = 1
+
+            call_command('reindex', 'extras.dummy', stdout=out)
+
+        search_backend.clear.assert_called_once_with(object_types=[content_type])
+        search_backend.cache.assert_called_once_with(iterator, remove_existing=False)
+        self.assertIn('1 entries cached.', out.getvalue())
+        self.assertIn('Total entries: 1', out.getvalue())
+
+    def test_reindex_app_label_uses_matching_indexers(self):
+        class DummyObjects:
+            @staticmethod
+            def iterator():
+                return iter(())
+
+        class DummyModel:
+            objects = DummyObjects()
+            _meta = SimpleNamespace(app_label='extras', model_name='dummy')
+
+        class OtherModel:
+            objects = DummyObjects()
+            _meta = SimpleNamespace(app_label='dcim', model_name='device')
+
+        content_type = object()
+        indexer = SimpleNamespace(model=DummyModel)
+        other_indexer = SimpleNamespace(model=OtherModel)
+
+        with (
+            patch(
+                'extras.management.commands.reindex.registry',
+                {'search': {'extras.dummy': indexer, 'dcim.device': other_indexer}},
+            ),
+            patch('extras.management.commands.reindex.search_backend') as search_backend,
+            patch.object(ContentType.objects, 'get_for_model', return_value=content_type),
+        ):
+            search_backend.clear.return_value = 0
+            search_backend.cache.return_value = 0
+            search_backend.size = 0
+
+            call_command('reindex', 'extras', stdout=StringIO())
+
+        search_backend.clear.assert_called_once_with(object_types=[content_type])
+        search_backend.cache.assert_called_once()
+
+    def test_reindex_unknown_registered_model(self):
+        with (
+            patch('extras.management.commands.reindex.registry', {'search': {}}),
+            self.assertRaisesMessage(CommandError, 'No indexer registered for extras.dummy'),
+        ):
+            call_command('reindex', 'extras.dummy', stdout=StringIO())
+
+    def test_reindex_app_with_no_registered_indexers(self):
+        with (
+            patch('extras.management.commands.reindex.registry', {'search': {}}),
+            self.assertRaisesMessage(CommandError, 'No indexers found'),
+        ):
+            call_command('reindex', 'extras', stdout=StringIO())
+
+    def test_invalid_model_label(self):
+        with self.assertRaisesMessage(CommandError, 'Invalid model'):
+            call_command('reindex', 'dcim.rack.extra', stdout=StringIO())
+
+
+class RenaturalizeTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Test Site', slug='test-site')
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Test Device Type',
+            slug='test-device-type',
+        )
+        device_role = DeviceRole.objects.create(
+            name='Test Device Role',
+            slug='test-device-role',
+            color='ff0000',
+        )
+        cls.device = Device.objects.create(
+            device_type=device_type,
+            role=device_role,
+            name='Test Device',
+            site=site,
+        )
+
+    def test_recalculates_natural_ordering_fields(self):
+        interface = Interface.objects.create(
+            device=self.device,
+            name='Ethernet10',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        field = next(field for field in Interface._meta.concrete_fields if type(field) is NaturalOrderingField)
+        Interface.objects.filter(pk=interface.pk).update(**{field.name: 'incorrect'})
+
+        out = StringIO()
+        call_command('renaturalize', 'dcim.Interface', verbosity=2, stdout=out)
+
+        interface.refresh_from_db()
+        expected = field.naturalize_function(interface.name, max_length=field.max_length)
+        self.assertEqual(getattr(interface, field.name), expected)
+        self.assertIn('Ethernet10 ->', out.getvalue())
+        self.assertIn('updated', out.getvalue())
+
+    def test_recalculates_with_default_verbosity(self):
+        interface = Interface.objects.create(
+            device=self.device,
+            name='Ethernet11',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+        field = next(field for field in Interface._meta.concrete_fields if type(field) is NaturalOrderingField)
+        Interface.objects.filter(pk=interface.pk).update(**{field.name: 'incorrect'})
+
+        out = StringIO()
+        call_command('renaturalize', 'dcim.Interface', verbosity=1, stdout=out)
+
+        interface.refresh_from_db()
+        expected = field.naturalize_function(interface.name, max_length=field.max_length)
+        self.assertEqual(getattr(interface, field.name), expected)
+        self.assertIn('Renaturalizing 1 models.', out.getvalue())
+        self.assertIn('Done.', out.getvalue())
+
+    def test_invalid_format(self):
+        with self.assertRaisesMessage(CommandError, 'Invalid format'):
+            call_command('renaturalize', 'dcim', stdout=StringIO())
+
+    def test_model_without_natural_ordering(self):
+        with self.assertRaisesMessage(CommandError, 'does not employ natural ordering'):
+            call_command('renaturalize', 'extras.Tag', stdout=StringIO())
+
+    def test_unknown_app_label(self):
+        with self.assertRaises(CommandError):
+            call_command('renaturalize', 'invalid.Interface', stdout=StringIO())
+
+    def test_unknown_model_name(self):
+        with self.assertRaisesMessage(CommandError, 'Unknown model: dcim.UnknownModel'):
+            call_command('renaturalize', 'dcim.UnknownModel', stdout=StringIO())
+
+    def test_get_models_discovers_all_models_with_natural_ordering_fields(self):
+        field = next(field for field in Interface._meta.concrete_fields if type(field) is NaturalOrderingField)
+        model = SimpleNamespace(_meta=SimpleNamespace(concrete_fields=[field]))
+        app_config = SimpleNamespace(models={'interface': model})
+
+        with patch('extras.management.commands.renaturalize.apps.get_app_configs', return_value=[app_config]):
+            models = renaturalize.Command()._get_models(())
+
+        self.assertEqual(models, [(model, [field])])
+
+
+class RunScriptTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_superuser(
+            username='admin',
+            email='admin@example.com',
+            password='password',
+        )
+
+    def test_enqueues_script_job(self):
+        class TestScript:
+            full_name = 'test.Script'
+
+            def as_form(self, data, files):
+                form = MagicMock()
+                form.is_valid.return_value = True
+                form.cleaned_data = {
+                    '_schedule_at': None,
+                    '_interval': None,
+                    '_commit': None,
+                    'name': data['name'],
+                }
+                form.errors.get_json_data.return_value = {}
+                return form
+
+        script_obj = SimpleNamespace(python_class=TestScript)
+        job = SimpleNamespace(duration='0 seconds')
+
+        with (
+            patch(
+                'extras.management.commands.runscript.get_module_and_script',
+                return_value=(None, script_obj),
+            ) as get_module_and_script,
+            patch(
+                'extras.management.commands.runscript.ScriptJob.enqueue',
+                return_value=job,
+            ) as enqueue,
+            patch('extras.management.commands.runscript.logging.getLogger'),
+        ):
+            call_command(
+                'runscript',
+                'test.Script',
+                user='admin',
+                data='{"name": "test"}',
+                stdout=StringIO(),
+            )
+
+        get_module_and_script.assert_called_once_with('test', 'Script')
+        enqueue.assert_called_once()
+        kwargs = enqueue.call_args.kwargs
+        self.assertEqual(kwargs['instance'], script_obj)
+        self.assertEqual(kwargs['user'], self.user)
+        self.assertTrue(kwargs['immediate'])
+        self.assertEqual(kwargs['data'], {'name': 'test'})
+        self.assertFalse(kwargs['commit'])
+
+    def test_invalid_script_data_raises_error_without_enqueueing_job(self):
+        class TestScript:
+            full_name = 'test.Script'
+
+            def as_form(self, data, files):
+                form = MagicMock()
+                form.is_valid.return_value = False
+                form.errors.get_json_data.return_value = {
+                    'name': [
+                        {'message': 'This field is required.'},
+                    ],
+                }
+                return form
+
+        script_obj = SimpleNamespace(python_class=TestScript)
+        logger = MagicMock()
+
+        with (
+            patch(
+                'extras.management.commands.runscript.get_module_and_script',
+                return_value=(None, script_obj),
+            ) as get_module_and_script,
+            patch('extras.management.commands.runscript.ScriptJob.enqueue') as enqueue,
+            patch('extras.management.commands.runscript.logging.getLogger', return_value=logger),
+        ):
+            with self.assertRaises(CommandError):
+                call_command(
+                    'runscript',
+                    'test.Script',
+                    user='admin',
+                    data='{}',
+                    stdout=StringIO(),
+                )
+
+        get_module_and_script.assert_called_once_with('test', 'Script')
+        enqueue.assert_not_called()
+        logger.error.assert_any_call('Data is not valid:')
+        logger.error.assert_any_call('\tname: This field is required.')
+
+    def test_missing_user_falls_back_to_superuser_and_empty_data(self):
+        class TestScript:
+            full_name = 'test.Script'
+
+            def as_form(self, data, files):
+                form = MagicMock()
+                form.is_valid.return_value = True
+                form.cleaned_data = {
+                    '_schedule_at': None,
+                    '_interval': None,
+                    '_commit': None,
+                }
+                form.errors.get_json_data.return_value = {}
+                return form
+
+        script_obj = SimpleNamespace(python_class=TestScript)
+        job = SimpleNamespace(duration='0 seconds')
+
+        with (
+            patch(
+                'extras.management.commands.runscript.get_module_and_script',
+                return_value=(None, script_obj),
+            ),
+            patch(
+                'extras.management.commands.runscript.ScriptJob.enqueue',
+                return_value=job,
+            ) as enqueue,
+            patch('extras.management.commands.runscript.logging.getLogger'),
+        ):
+            call_command(
+                'runscript',
+                'test.Script',
+                user='missing-user',
+                stdout=StringIO(),
+            )
+
+        kwargs = enqueue.call_args.kwargs
+        self.assertEqual(kwargs['user'], self.user)
+        self.assertEqual(kwargs['data'], {})
+
+    def test_no_user_argument_falls_back_to_first_superuser(self):
+        class TestScript:
+            full_name = 'test.Script'
+
+            def as_form(self, data, files):
+                form = MagicMock()
+                form.is_valid.return_value = True
+                form.cleaned_data = {
+                    '_schedule_at': None,
+                    '_interval': None,
+                    '_commit': None,
+                }
+                form.errors.get_json_data.return_value = {}
+                return form
+
+        script_obj = SimpleNamespace(python_class=TestScript)
+        job = SimpleNamespace(duration='0 seconds')
+
+        with (
+            patch(
+                'extras.management.commands.runscript.get_module_and_script',
+                return_value=(None, script_obj),
+            ),
+            patch(
+                'extras.management.commands.runscript.ScriptJob.enqueue',
+                return_value=job,
+            ) as enqueue,
+            patch('extras.management.commands.runscript.logging.getLogger'),
+        ):
+            call_command('runscript', 'test.Script', stdout=StringIO())
+
+        self.assertEqual(enqueue.call_args.kwargs['user'], self.user)
+
+
+class WebhookReceiverTestCase(TestCase):
+    def test_starts_http_server(self):
+        out = StringIO()
+
+        with (
+            patch('extras.management.commands.webhook_receiver.HTTPServer') as http_server,
+            patch.object(WebhookHandler, 'show_headers', True),
+        ):
+            server = http_server.return_value
+            server.serve_forever.side_effect = KeyboardInterrupt
+
+            call_command(
+                'webhook_receiver',
+                port=9999,
+                no_headers=True,
+                stdout=out,
+            )
+
+            self.assertFalse(WebhookHandler.show_headers)
+
+        http_server.assert_called_once_with(('localhost', 9999), WebhookHandler)
+        server.serve_forever.assert_called_once_with()
+        self.assertIn('Listening on port http://localhost:9999', out.getvalue())
+        self.assertIn('Exiting', out.getvalue())
+
+    def test_handler_routes_arbitrary_http_methods(self):
+        handler = object.__new__(WebhookHandler)
+
+        self.assertEqual(handler.__getattr__('do_PATCH').__func__, WebhookHandler.do_ANY)
+        with self.assertRaises(AttributeError):
+            handler.__getattr__('missing')
+
+    def test_handler_logs_request_message(self):
+        handler = object.__new__(WebhookHandler)
+        handler.date_time_string = MagicMock(return_value='now')
+        handler.address_string = MagicMock(return_value='127.0.0.1')
+
+        with (
+            patch('extras.management.commands.webhook_receiver.request_counter', 7),
+            patch('builtins.print') as print_,
+        ):
+            handler.log_message('%s', 'message')
+
+        print_.assert_called_once_with('[7] now 127.0.0.1 message')
+
+    def test_handler_accepts_json_request_body(self):
+        handler = object.__new__(WebhookHandler)
+        body = b'{"ok": true}'
+        handler.headers = {
+            'Content-Length': str(len(body)),
+            'Content-Type': 'application/json',
+            'X-Test': 'value',
+        }
+        handler.rfile = BytesIO(body)
+        handler.wfile = BytesIO()
+        handler.send_response = MagicMock()
+        handler.end_headers = MagicMock()
+        handler.show_headers = True
+
+        with (
+            patch('extras.management.commands.webhook_receiver.request_counter', 1),
+            patch('builtins.print') as print_,
+        ):
+            handler.do_ANY()
+            self.assertEqual(webhook_receiver.request_counter, 2)
+
+        handler.send_response.assert_called_once_with(200)
+        handler.end_headers.assert_called_once_with()
+        self.assertEqual(handler.wfile.getvalue(), b'Webhook received!\n')
+        print_.assert_any_call('X-Test: value')
+        print_.assert_any_call('{\n    "ok": true\n}')
+        print_.assert_any_call('Completed request #1')
+
+    def test_handler_prints_no_body_when_content_length_is_missing(self):
+        handler = object.__new__(WebhookHandler)
+        handler.headers = {}
+        handler.rfile = BytesIO()
+        handler.wfile = BytesIO()
+        handler.send_response = MagicMock()
+        handler.end_headers = MagicMock()
+        handler.show_headers = False
+
+        with (
+            patch('extras.management.commands.webhook_receiver.request_counter', 1),
+            patch('builtins.print') as print_,
+        ):
+            handler.do_ANY()
+
+        print_.assert_any_call('(No body)')
+        print_.assert_any_call('Completed request #1')

+ 410 - 13
netbox/extras/tests/test_models.py

@@ -1,6 +1,7 @@
 import io
 import io
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
+from types import SimpleNamespace
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -9,11 +10,13 @@ from django.core.files.storage import Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import ValidationError
 from django.forms import ValidationError
 from django.test import TestCase, tag
 from django.test import TestCase, tag
+from jinja2 import DebugUndefined, StrictUndefined, TemplateError, TemplateSyntaxError, UndefinedError
 from PIL import Image
 from PIL import Image
 
 
 from core.events import OBJECT_CREATED
 from core.events import OBJECT_CREATED
 from core.models import AutoSyncRecord, DataSource, ObjectType
 from core.models import AutoSyncRecord, DataSource, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
+from extras.constants import DEFAULT_MIME_TYPE
 from extras.models import (
 from extras.models import (
     ConfigContext,
     ConfigContext,
     ConfigContextProfile,
     ConfigContextProfile,
@@ -21,11 +24,15 @@ from extras.models import (
     EventRule,
     EventRule,
     ExportTemplate,
     ExportTemplate,
     ImageAttachment,
     ImageAttachment,
+    TableConfig,
     Tag,
     Tag,
     TaggedItem,
     TaggedItem,
 )
 )
+from extras.models.mixins import RenderTemplateMixin
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from utilities.jinja2 import render_jinja2
+from utilities.tables import get_table_for_model
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
@@ -67,7 +74,7 @@ class OverwriteStyleMemoryStorage(Storage):
         return f'https://example.invalid/{name}'
         return f'https://example.invalid/{name}'
 
 
 
 
-class ImageAttachmentTests(TestCase):
+class ImageAttachmentTestCase(TestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
         cls.ct_rack = ContentType.objects.get_by_natural_key('dcim', 'rack')
@@ -185,7 +192,26 @@ class ImageAttachmentTests(TestCase):
         self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
         self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
 
 
 
 
-class TagTest(TestCase):
+class TableConfigTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_ct = ContentType.objects.get_for_model(Site)
+        cls.table_name = get_table_for_model(Site).__name__
+
+    def test_clean_accepts_ordering_none(self):
+        """clean() must accept ordering=None (field is null=True)."""
+        tc = TableConfig(
+            object_type=self.site_ct,
+            table=self.table_name,
+            name='No ordering',
+            columns=['name'],
+            # ordering left unset (defaults to None)
+        )
+        # Must not raise TypeError: 'NoneType' object is not iterable
+        tc.full_clean()
+
+
+class TagTestCase(TestCase):
 
 
     def test_default_ordering_weight_then_name_is_set(self):
     def test_default_ordering_weight_then_name_is_set(self):
         Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000)
         Tag.objects.create(name='Tag 1', slug='tag-1', weight=3000)
@@ -244,7 +270,7 @@ class TagTest(TestCase):
             sitegroup.tags.add(tag)
             sitegroup.tags.add(tag)
 
 
 
 
-class ConfigContextTest(TestCase):
+class ConfigContextTestCase(TestCase):
     """
     """
     These test cases deal with the weighting, ordering, and deep merge logic of config context data.
     These test cases deal with the weighting, ordering, and deep merge logic of config context data.
 
 
@@ -792,7 +818,7 @@ class ConfigContextTest(TestCase):
         self.assertTrue(distinct_subqueries[0].distinct)
         self.assertTrue(distinct_subqueries[0].distinct)
 
 
 
 
-class ConfigTemplateTest(TestCase):
+class ConfigTemplateTestCase(TestCase):
     """
     """
     TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
     TODO: These test cases deal with the weighting, ordering, and deep merge logic of config context data.
     """
     """
@@ -905,7 +931,7 @@ class ConfigTemplateTest(TestCase):
             self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
             self.assertEqual(autosync_records.count(), 0, "AutoSyncRecord should be deleted after detaching")
 
 
 
 
-class ConfigTemplateDebugTest(TestCase):
+class ConfigTemplateDebugTestCase(TestCase):
     """
     """
     Tests for the ConfigTemplate debug field and its effect on template rendering error output.
     Tests for the ConfigTemplate debug field and its effect on template rendering error output.
     """
     """
@@ -925,35 +951,29 @@ class ConfigTemplateDebugTest(TestCase):
 
 
     def test_template_error_non_debug_no_traceback(self):
     def test_template_error_non_debug_no_traceback(self):
         """In non-debug mode, a TemplateError raises with no traceback exposure."""
         """In non-debug mode, a TemplateError raises with no traceback exposure."""
-        from jinja2 import TemplateError
         t = self._make_template("{{ unclosed", debug=False)
         t = self._make_template("{{ unclosed", debug=False)
         with self.assertRaises(TemplateError):
         with self.assertRaises(TemplateError):
             t.render({})
             t.render({})
 
 
     def test_template_error_debug_mode_raises(self):
     def test_template_error_debug_mode_raises(self):
         """In debug mode, a TemplateError still raises (callers handle display)."""
         """In debug mode, a TemplateError still raises (callers handle display)."""
-        from jinja2 import TemplateError
         t = self._make_template("{{ unclosed", debug=True)
         t = self._make_template("{{ unclosed", debug=True)
         with self.assertRaises(TemplateError):
         with self.assertRaises(TemplateError):
             t.render({})
             t.render({})
 
 
     def test_render_jinja2_debug_extension_enabled(self):
     def test_render_jinja2_debug_extension_enabled(self):
         """When debug=True, the Jinja2 debug extension is loaded in the environment."""
         """When debug=True, the Jinja2 debug extension is loaded in the environment."""
-        from utilities.jinja2 import render_jinja2
         # The {% debug %} tag is only available when the debug extension is loaded.
         # The {% debug %} tag is only available when the debug extension is loaded.
         output = render_jinja2("{% debug %}", {}, debug=True)
         output = render_jinja2("{% debug %}", {}, debug=True)
         self.assertIsInstance(output, str)
         self.assertIsInstance(output, str)
 
 
     def test_render_jinja2_debug_extension_not_loaded_by_default(self):
     def test_render_jinja2_debug_extension_not_loaded_by_default(self):
         """When debug=False, the {% debug %} tag is not available."""
         """When debug=False, the {% debug %} tag is not available."""
-        from jinja2 import TemplateSyntaxError
-
-        from utilities.jinja2 import render_jinja2
         with self.assertRaises(TemplateSyntaxError):
         with self.assertRaises(TemplateSyntaxError):
             render_jinja2("{% debug %}", {}, debug=False)
             render_jinja2("{% debug %}", {}, debug=False)
 
 
 
 
-class ExportTemplateContextTest(TestCase):
+class ExportTemplateContextTestCase(TestCase):
     """
     """
     Tests for ExportTemplate.get_context() including public model population.
     Tests for ExportTemplate.get_context() including public model population.
     """
     """
@@ -986,7 +1006,188 @@ class ExportTemplateContextTest(TestCase):
         self.assertIs(ctx['dcim']['Site'], Site)
         self.assertIs(ctx['dcim']['Site'], Site)
 
 
 
 
-class EventRuleTest(TestCase):
+def finalize_none_to_dash(value):
+    """
+    Module-level helper used by RenderTemplateMixinRenderTestCase.test_environment_params_finalize_path_import.
+    Exported so it can be referenced by dotted path from a Jinja environment_params value.
+    """
+    return '-' if value is None else value
+
+
+class RenderTemplateMixinRenderTestCase(TestCase):
+    """
+    Tests for RenderTemplateMixin.render() and get_environment_params(), exercised via ConfigTemplate.
+    """
+
+    def test_render_basic_context(self):
+        t = ConfigTemplate(name='basic', template_code='Hello {{ name }}')
+        self.assertEqual(t.render({'name': 'world'}), 'Hello world')
+
+    def test_render_normalizes_crlf(self):
+        t = ConfigTemplate(name='crlf', template_code='line1\r\nline2\r\nline3')
+        self.assertEqual(t.render({}), 'line1\nline2\nline3')
+
+    def test_render_passes_environment_params(self):
+        # With trim_blocks + lstrip_blocks, block tags don't emit their surrounding whitespace.
+        template_code = '{% if x %}\n    {% if y %}\n        VALUE\n    {% endif %}\n{% endif %}'
+        plain = ConfigTemplate(name='plain', template_code=template_code)
+        trimmed = ConfigTemplate(
+            name='trimmed',
+            template_code=template_code,
+            environment_params={'trim_blocks': True, 'lstrip_blocks': True},
+        )
+        ctx = {'x': True, 'y': True}
+        self.assertNotEqual(plain.render(ctx), trimmed.render(ctx))
+        self.assertEqual(trimmed.render(ctx).strip(), 'VALUE')
+
+    def test_environment_params_undefined_path_import(self):
+        # Default Undefined renders nothing for a missing variable.
+        default = ConfigTemplate(name='default', template_code='{{ missing }}')
+        self.assertEqual(default.render({}), '')
+
+        # StrictUndefined (resolved from its dotted path) raises on access.
+        strict = ConfigTemplate(
+            name='strict',
+            template_code='{{ missing }}',
+            environment_params={'undefined': 'jinja2.StrictUndefined'},
+        )
+        with self.assertRaises(UndefinedError):
+            strict.render({})
+
+    def test_environment_params_finalize_legacy_resolution(self):
+        """
+        Existing finalize values continue to resolve via import_string() as a
+        legacy carve-out (CVE-2026-29514). New use is blocked by clean().
+        """
+        t = ConfigTemplate(
+            name='finalize',
+            template_code='{{ v }}',
+            environment_params={'finalize': 'extras.tests.test_models.finalize_none_to_dash'},
+        )
+        self.assertEqual(t.render({'v': None}), '-')
+        self.assertEqual(t.render({'v': 'abc'}), 'abc')
+
+    def test_get_environment_params_handles_none(self):
+        # The environment_params field may be cleared; ensure the mixin returns a dict (not None).
+        t = ConfigTemplate(name='empty', template_code='ok', environment_params=None)
+        self.assertEqual(t.get_environment_params(), {})
+
+    def test_get_environment_params_resolves_path_imports(self):
+        t = ConfigTemplate(
+            name='resolve',
+            template_code='ok',
+            environment_params={'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True},
+        )
+        params = t.get_environment_params()
+        self.assertIs(params['undefined'], StrictUndefined)
+        self.assertIs(params['trim_blocks'], True)
+
+    def test_get_environment_params_does_not_mutate_field(self):
+        # Resolving path imports must not replace the string values stored on the model field.
+        t = ConfigTemplate(
+            name='no-mutate',
+            template_code='ok',
+            environment_params={'undefined': 'jinja2.StrictUndefined'},
+        )
+        t.get_environment_params()
+        t.get_environment_params()
+        self.assertEqual(t.environment_params, {'undefined': 'jinja2.StrictUndefined'})
+
+
+class RenderTemplateMixinResponseTestCase(TestCase):
+    """
+    Tests for RenderTemplateMixin.render_to_response() HTTP behavior.
+    """
+
+    def test_response_default_mime_type(self):
+        t = ConfigTemplate(name='t', template_code='ok')
+        response = t.render_to_response({})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
+
+    def test_response_custom_mime_type(self):
+        t = ConfigTemplate(name='t', template_code='{}', mime_type='application/json')
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Type'], 'application/json')
+
+    def test_response_attachment_with_file_name(self):
+        t = ConfigTemplate(
+            name='t', template_code='ok', file_name='router1', file_extension='cfg', as_attachment=True,
+        )
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1.cfg"')
+
+    def test_response_attachment_filename_from_queryset(self):
+        Site.objects.create(name='Site 1', slug='site-1')
+        t = ExportTemplate(
+            name='t',
+            template_code='{% for obj in queryset %}{{ obj.name }}{% endfor %}',
+            file_extension='txt',
+            as_attachment=True,
+        )
+        response = t.render_to_response(queryset=Site.objects.all())
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+
+    def test_response_attachment_filename_from_device_context(self):
+        t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
+        device = SimpleNamespace(name='router1')
+        response = t.render_to_response(context={'device': device})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="router1"')
+
+    def test_response_attachment_fallback_filename(self):
+        # No file_name, no queryset, no device/vm key in context: filename falls back to "output".
+        t = ConfigTemplate(name='t', template_code='ok', as_attachment=True)
+        response = t.render_to_response({})
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="output"')
+
+    def test_response_as_attachment_false_omits_disposition(self):
+        t = ConfigTemplate(name='t', template_code='ok', file_name='router1', as_attachment=False)
+        response = t.render_to_response({})
+        self.assertNotIn('Content-Disposition', response)
+
+    def test_response_body_matches_render(self):
+        t = ConfigTemplate(name='t', template_code='Hello {{ name }}')
+        rendered = t.render({'name': 'world'})
+        response = t.render_to_response({'name': 'world'})
+        self.assertEqual(response.content.decode(), rendered)
+
+
+class ExportTemplateRenderTestCase(TestCase):
+    """
+    Tests for ExportTemplate.render() with a queryset bound into the template context.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.bulk_create([
+            Site(name='Site A', slug='site-a'),
+            Site(name='Site B', slug='site-b'),
+            Site(name='Site C', slug='site-c'),
+        ])
+
+    def test_render_iterates_queryset(self):
+        t = ExportTemplate(
+            name='sites',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+        )
+        queryset = Site.objects.order_by('name')
+        output = t.render(queryset=queryset)
+        self.assertEqual(output, 'Site A\nSite B\nSite C\n')
+
+    def test_render_to_response_for_queryset(self):
+        t = ExportTemplate(
+            name='sites',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            file_extension='txt',
+        )
+        response = t.render_to_response(queryset=Site.objects.order_by('name'))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], DEFAULT_MIME_TYPE)
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+        self.assertEqual(response.content.decode(), 'Site A\nSite B\nSite C\n')
+
+
+class EventRuleTestCase(TestCase):
 
 
     def test_action_data_clean_accepts_dict(self):
     def test_action_data_clean_accepts_dict(self):
         """
         """
@@ -1005,3 +1206,199 @@ class EventRuleTest(TestCase):
             with self.assertRaises(ValidationError) as cm:
             with self.assertRaises(ValidationError) as cm:
                 rule.clean()
                 rule.clean()
             self.assertIn('action_data', cm.exception.message_dict)
             self.assertIn('action_data', cm.exception.message_dict)
+
+
+class JinjaEnvironmentParamsCleanTestCase(TestCase):
+    """Tests for RenderTemplateMixin.clean() validation of environment_params."""
+
+    def _make_template(self, environment_params):
+        return ConfigTemplate(
+            name='test',
+            template_code='{{ "test" }}',
+            environment_params=environment_params,
+        )
+
+    def test_allowed_scalar_params_pass(self):
+        template = self._make_template({'trim_blocks': True, 'lstrip_blocks': True})
+        template.clean()
+
+    def test_autoescape_boolean_passes(self):
+        template = self._make_template({'autoescape': True})
+        template.clean()
+
+    def test_valid_undefined_passes(self):
+        for value in (
+            'jinja2.Undefined',
+            'jinja2.ChainableUndefined',
+            'jinja2.DebugUndefined',
+            'jinja2.StrictUndefined',
+        ):
+            template = self._make_template({'undefined': value})
+            template.clean()
+
+    def test_invalid_undefined_rejected(self):
+        template = self._make_template({'undefined': 'subprocess.getoutput'})
+        with self.assertRaises(ValidationError) as cm:
+            template.clean()
+        self.assertIn('environment_params', cm.exception.message_dict)
+
+    def test_unknown_key_rejected(self):
+        template = self._make_template({'extensions': ['os']})
+        with self.assertRaises(ValidationError) as cm:
+            template.clean()
+        self.assertIn('environment_params', cm.exception.message_dict)
+
+    def test_finalize_blocked_from_new_use(self):
+        template = self._make_template({'finalize': 'subprocess.getoutput'})
+        with self.assertRaises(ValidationError) as cm:
+            template.clean()
+        self.assertIn('environment_params', cm.exception.message_dict)
+
+    def test_empty_params_pass(self):
+        template = self._make_template({})
+        template.clean()
+
+    def test_none_params_pass(self):
+        template = self._make_template(None)
+        template.clean()
+
+    def test_exporttemplate_clean_rejects_unknown_key(self):
+        """MRO smoke test: ExportTemplate.clean() reaches RenderTemplateMixin.clean()."""
+        obj = ExportTemplate(
+            name='test',
+            template_code='{{ "test" }}',
+            environment_params={'loader': 'some.loader'},
+        )
+        with self.assertRaises(ValidationError) as cm:
+            obj.clean()
+        self.assertIn('environment_params', cm.exception.message_dict)
+
+    def test_configtemplate_clean_rejects_finalize(self):
+        """MRO smoke test: ConfigTemplate.clean() reaches RenderTemplateMixin.clean()."""
+        obj = ConfigTemplate(
+            name='test',
+            template_code='{{ "test" }}',
+            environment_params={'finalize': 'subprocess.getoutput'},
+        )
+        with self.assertRaises(ValidationError) as cm:
+            obj.clean()
+        self.assertIn('environment_params', cm.exception.message_dict)
+
+
+class JinjaEnvironmentParamsFilterTestCase(TestCase):
+    """Tests for RenderTemplateMixin._filter_environment_params()."""
+
+    def test_allowed_keys_pass_through(self):
+        params = {'trim_blocks': True, 'autoescape': False}
+        result = RenderTemplateMixin._filter_environment_params(params)
+        self.assertEqual(result, params)
+
+    def test_unknown_keys_stripped(self):
+        params = {'extensions': ['os'], 'loader': 'x', 'trim_blocks': True}
+        result = RenderTemplateMixin._filter_environment_params(params)
+        self.assertEqual(result, {'trim_blocks': True})
+
+    def test_finalize_preserved_as_legacy(self):
+        params = {'finalize': 'some.module.func', 'trim_blocks': True}
+        result = RenderTemplateMixin._filter_environment_params(params)
+        self.assertEqual(result, params)
+
+    def test_empty_params(self):
+        self.assertEqual(RenderTemplateMixin._filter_environment_params({}), {})
+
+
+class JinjaEnvironmentParamsResolveTestCase(TestCase):
+    """Tests for RenderTemplateMixin._resolve_mapped_params()."""
+
+    def test_undefined_resolved_to_class(self):
+        params = {'undefined': 'jinja2.StrictUndefined'}
+        result = RenderTemplateMixin._resolve_mapped_params(params)
+        self.assertIs(result['undefined'], StrictUndefined)
+
+    def test_unrecognized_undefined_value_passed_through(self):
+        params = {'undefined': 'not.a.real.class'}
+        result = RenderTemplateMixin._resolve_mapped_params(params)
+        self.assertEqual(result['undefined'], 'not.a.real.class')
+
+    def test_scalar_params_passed_through(self):
+        params = {'trim_blocks': True, 'autoescape': False}
+        result = RenderTemplateMixin._resolve_mapped_params(params)
+        self.assertEqual(result, params)
+
+    def test_empty_params(self):
+        self.assertEqual(RenderTemplateMixin._resolve_mapped_params({}), {})
+
+
+class JinjaEnvironmentParamsFinalizeTestCase(TestCase):
+    """Tests for RenderTemplateMixin._resolve_finalize() legacy carve-out."""
+
+    def test_finalize_string_resolved_via_import_string(self):
+        params = {'finalize': 'extras.tests.test_models.finalize_none_to_dash'}
+        result = RenderTemplateMixin._resolve_finalize(params)
+        self.assertIs(result['finalize'], finalize_none_to_dash)
+
+    def test_finalize_non_string_passed_through(self):
+        params = {'finalize': 42}
+        result = RenderTemplateMixin._resolve_finalize(params)
+        self.assertEqual(result['finalize'], 42)
+
+    def test_no_finalize_key_unchanged(self):
+        params = {'trim_blocks': True}
+        result = RenderTemplateMixin._resolve_finalize(params)
+        self.assertEqual(result, {'trim_blocks': True})
+
+    def test_invalid_import_path_raises_import_error(self):
+        params = {'finalize': 'nonexistent.module.func'}
+        with self.assertRaises(ImportError):
+            RenderTemplateMixin._resolve_finalize(params)
+
+    def test_empty_params(self):
+        self.assertEqual(RenderTemplateMixin._resolve_finalize({}), {})
+
+
+class JinjaEnvironmentParamsIntegrationTestCase(TestCase):
+    """Integration tests for get_environment_params() end-to-end."""
+
+    def _make_template(self, environment_params):
+        return ConfigTemplate(
+            name='test',
+            template_code='{{ "test" }}',
+            environment_params=environment_params,
+        )
+
+    def test_full_pipeline_with_undefined(self):
+        template = self._make_template({'undefined': 'jinja2.StrictUndefined', 'trim_blocks': True})
+        params = template.get_environment_params()
+        self.assertIs(params['undefined'], StrictUndefined)
+        self.assertIs(params['trim_blocks'], True)
+
+    def test_full_pipeline_strips_unknown_and_resolves(self):
+        template = self._make_template({
+            'extensions': ['os'],
+            'undefined': 'jinja2.DebugUndefined',
+            'trim_blocks': True,
+        })
+        params = template.get_environment_params()
+        self.assertNotIn('extensions', params)
+        self.assertIs(params['undefined'], DebugUndefined)
+        self.assertIs(params['trim_blocks'], True)
+
+    def test_full_pipeline_finalize_resolves(self):
+        template = self._make_template({
+            'finalize': 'extras.tests.test_models.finalize_none_to_dash',
+        })
+        params = template.get_environment_params()
+        self.assertIs(params['finalize'], finalize_none_to_dash)
+
+    def test_does_not_mutate_stored_value(self):
+        template = self._make_template({'undefined': 'jinja2.StrictUndefined'})
+        template.get_environment_params()
+        self.assertEqual(template.environment_params['undefined'], 'jinja2.StrictUndefined')
+
+    def test_none_environment_params(self):
+        template = self._make_template(None)
+        self.assertEqual(template.get_environment_params(), {})
+
+    def test_empty_environment_params(self):
+        template = self._make_template({})
+        self.assertEqual(template.get_environment_params(), {})

+ 1 - 1
netbox/extras/tests/test_scripts.py

@@ -32,7 +32,7 @@ JSON_DATA = """
 """
 """
 
 
 
 
-class ScriptVariablesTest(TestCase):
+class ScriptVariablesTestCase(TestCase):
 
 
     def test_stringvar(self):
     def test_stringvar(self):
 
 

+ 297 - 0
netbox/extras/tests/test_signals.py

@@ -0,0 +1,297 @@
+import uuid
+from unittest.mock import patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ValidationError
+from django.test import RequestFactory, TestCase, override_settings
+
+from core.events import JOB_COMPLETED, JOB_STARTED, OBJECT_DELETED, OBJECT_UPDATED
+from core.models import Job, ObjectType
+from core.signals import job_end, job_start
+from dcim.choices import SiteStatusChoices
+from dcim.models import Region, Site
+from extras import signals
+from extras.choices import CustomFieldTypeChoices, EventRuleActionChoices
+from extras.models import CustomField, EventRule, Notification, Subscription, Tag, Webhook
+from extras.validators import CustomValidator
+from netbox.context_managers import event_tracking
+from users.models import User
+from utilities.exceptions import AbortRequest
+
+
+def _build_request(user=None):
+    request = RequestFactory().get('/')
+    request.id = uuid.uuid4()
+    request.user = user
+    return request
+
+
+class CustomFieldRenameSignalTestCase(TestCase):
+    """
+    Verify extras.signals.handle_cf_renamed migrates stored custom-field data when a
+    CustomField is renamed.
+    """
+
+    def test_renaming_custom_field_moves_object_data(self):
+        site_type = ContentType.objects.get_for_model(Site)
+        cf = CustomField.objects.create(
+            name='asset_tag',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+        )
+        cf.object_types.set([site_type])
+        site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
+
+        cf.name = 'inventory_id'
+        cf.save()
+
+        site.refresh_from_db()
+        self.assertNotIn('asset_tag', site.custom_field_data)
+        self.assertEqual(site.custom_field_data['inventory_id'], 'A123')
+
+    def test_creating_custom_field_does_not_attempt_rename(self):
+        # Should not raise — newly-created custom fields have no prior name to migrate.
+        site_type = ContentType.objects.get_for_model(Site)
+        cf = CustomField.objects.create(
+            name='cf1',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+        )
+        cf.object_types.set([site_type])
+
+
+class CustomFieldDeletedSignalTestCase(TestCase):
+    """
+    Verify extras.signals.handle_cf_deleted strips stale data from associated objects
+    when a CustomField is deleted.
+    """
+
+    def test_deleting_custom_field_clears_object_data(self):
+        site_type = ContentType.objects.get_for_model(Site)
+        cf = CustomField.objects.create(
+            name='asset_tag',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+        )
+        cf.object_types.set([site_type])
+        site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
+
+        cf.delete()
+
+        site.refresh_from_db()
+        self.assertNotIn('asset_tag', site.custom_field_data)
+
+
+class CustomFieldObjectTypeSignalTestCase(TestCase):
+    """
+    Verify extras.signals.handle_cf_added_obj_types and handle_cf_removed_obj_types
+    populate or strip default values when a CustomField's object_types m2m changes.
+    """
+
+    def test_adding_object_type_populates_default_value(self):
+        site_type = ContentType.objects.get_for_model(Site)
+        Site.objects.create(name='Site 1', slug='site-1')
+        cf = CustomField.objects.create(
+            name='asset_tag',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+            default='UNTAGGED',
+        )
+
+        cf.object_types.set([site_type])
+
+        site = Site.objects.get(slug='site-1')
+        self.assertEqual(site.custom_field_data.get('asset_tag'), 'UNTAGGED')
+
+    def test_removing_object_type_clears_stored_data(self):
+        site_type = ContentType.objects.get_for_model(Site)
+        cf = CustomField.objects.create(
+            name='asset_tag',
+            type=CustomFieldTypeChoices.TYPE_TEXT,
+        )
+        cf.object_types.set([site_type])
+        site = Site.objects.create(name='Site 1', slug='site-1', custom_field_data={'asset_tag': 'A123'})
+
+        cf.object_types.remove(site_type)
+
+        site.refresh_from_db()
+        self.assertNotIn('asset_tag', site.custom_field_data)
+
+
+class RunSaveValidatorsSignalTestCase(TestCase):
+    """
+    Verify extras.signals.run_save_validators invokes any configured CUSTOM_VALIDATORS
+    when a model emits the post_clean signal.
+    """
+
+    @override_settings(CUSTOM_VALIDATORS={'dcim.site': [CustomValidator({'name': {'eq': 'allowed'}})]})
+    def test_validation_failure_raises_validation_error(self):
+        with self.assertRaises(ValidationError):
+            Site(name='blocked', slug='blocked', status=SiteStatusChoices.STATUS_ACTIVE).clean()
+
+    @override_settings(CUSTOM_VALIDATORS={'dcim.site': [CustomValidator({'name': {'eq': 'allowed'}})]})
+    def test_validation_success_does_not_raise(self):
+        Site(name='allowed', slug='allowed', status=SiteStatusChoices.STATUS_ACTIVE).clean()
+
+
+class ValidateAssignedTagsSignalTestCase(TestCase):
+    """
+    Verify extras.signals.validate_assigned_tags rejects Tags that are restricted to
+    object types incompatible with the target object.
+    """
+
+    def test_restricted_tag_blocks_incompatible_object(self):
+        region_type = ContentType.objects.get_for_model(Region)
+        tag = Tag.objects.create(name='RegionOnly', slug='regiononly')
+        tag.object_types.set([region_type])
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        with self.assertRaises(AbortRequest):
+            site.tags.add(tag)
+
+    def test_restricted_tag_allows_compatible_object(self):
+        site_type = ContentType.objects.get_for_model(Site)
+        tag = Tag.objects.create(name='SiteOnly', slug='siteonly')
+        tag.object_types.set([site_type])
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        site.tags.add(tag)
+        self.assertEqual(list(site.tags.all()), [tag])
+
+    def test_unrestricted_tag_is_always_permitted(self):
+        tag = Tag.objects.create(name='Anywhere', slug='anywhere')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        site.tags.add(tag)
+        self.assertEqual(list(site.tags.all()), [tag])
+
+
+class JobEventRulesSignalTestCase(TestCase):
+    """
+    Verify extras.signals.process_job_start_event_rules and process_job_end_event_rules
+    invoke any EventRule registered for JOB_STARTED / JOB_COMPLETED on the sender's
+    object_type.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.site_type = ObjectType.objects.get_for_model(Site)
+        webhook = Webhook.objects.create(
+            name='Webhook',
+            payload_url='http://localhost/',
+            secret='secret',
+        )
+        webhook_type = ObjectType.objects.get_for_model(Webhook)
+        cls.start_rule = EventRule.objects.create(
+            name='Job Start Rule',
+            event_types=[JOB_STARTED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=webhook_type,
+            action_object_id=webhook.pk,
+        )
+        cls.start_rule.object_types.set([cls.site_type])
+        cls.end_rule = EventRule.objects.create(
+            name='Job End Rule',
+            event_types=[JOB_COMPLETED],
+            action_type=EventRuleActionChoices.WEBHOOK,
+            action_object_type=webhook_type,
+            action_object_id=webhook.pk,
+        )
+        cls.end_rule.object_types.set([cls.site_type])
+
+    def _create_job(self):
+        return Job.objects.create(
+            object_type=self.site_type,
+            name='test-job',
+            job_id=uuid.uuid4(),
+            data={'foo': 1},
+        )
+
+    def test_job_start_filters_to_matching_event_rules(self):
+        sender = self._create_job()
+
+        with patch('extras.signals.process_event_rules') as process_event_rules:
+            job_start.send(sender=sender)
+
+        process_event_rules.assert_called_once()
+        rules_qs = process_event_rules.call_args.args[0]
+        self.assertEqual(list(rules_qs.values_list('pk', flat=True)), [self.start_rule.pk])
+
+    def test_job_end_filters_to_matching_event_rules(self):
+        sender = self._create_job()
+
+        with patch('extras.signals.process_event_rules') as process_event_rules:
+            job_end.send(sender=sender)
+
+        process_event_rules.assert_called_once()
+        rules_qs = process_event_rules.call_args.args[0]
+        self.assertEqual(list(rules_qs.values_list('pk', flat=True)), [self.end_rule.pk])
+
+
+class NotifyObjectChangedSignalTestCase(TestCase):
+    """
+    Verify extras.signals.notify_object_changed creates Notifications for subscribed
+    users on object update and deletion, and skips creation otherwise.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='alice', password='pw')
+
+    def test_creating_object_does_not_create_notifications(self):
+        # Subscribe a user to the object BEFORE invoking the handler so the
+        # discriminating branch (created=True early return) is genuinely
+        # exercised. Without the subscription this assertion passes trivially.
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_type = ContentType.objects.get_for_model(Site)
+        Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
+        Notification.objects.all().delete()
+
+        signals.notify_object_changed(sender=Site, instance=site, created=True)
+
+        self.assertFalse(Notification.objects.exists())
+
+    def test_updating_subscribed_object_creates_update_notification(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_type = ContentType.objects.get_for_model(Site)
+        Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
+
+        site.description = 'updated'
+        site.save()
+
+        notification = Notification.objects.get(user=self.user)
+        self.assertEqual(notification.object_type, site_type)
+        self.assertEqual(notification.object_id, site.pk)
+        self.assertEqual(notification.event_type, OBJECT_UPDATED)
+
+    def test_deleting_subscribed_object_creates_delete_notification(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_type = ContentType.objects.get_for_model(Site)
+        Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
+
+        site.delete()
+
+        notification = Notification.objects.get(user=self.user)
+        self.assertEqual(notification.event_type, OBJECT_DELETED)
+
+    def test_updating_unsubscribed_object_creates_no_notification(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        site.description = 'updated'
+        site.save()
+
+        self.assertEqual(Notification.objects.count(), 0)
+
+    def test_updating_object_replaces_existing_notification(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site_type = ContentType.objects.get_for_model(Site)
+        Subscription.objects.create(user=self.user, object_type=site_type, object_id=site.pk)
+
+        # Trigger two updates within a single request; only one Notification should remain.
+        request = _build_request(user=self.user)
+        with event_tracking(request):
+            site.description = 'first'
+            site.save()
+            site.description = 'second'
+            site.save()
+
+        self.assertEqual(
+            Notification.objects.filter(user=self.user, object_type=site_type, object_id=site.pk).count(),
+            1,
+        )

+ 18 - 18
netbox/extras/tests/test_tables.py

@@ -3,31 +3,31 @@ from extras.tables import *
 from utilities.testing import TableTestCases
 from utilities.testing import TableTestCases
 
 
 
 
-class CustomFieldTableTest(TableTestCases.StandardTableTestCase):
+class CustomFieldTableTestCase(TableTestCases.StandardTableTestCase):
     table = CustomFieldTable
     table = CustomFieldTable
 
 
 
 
-class CustomFieldChoiceSetTableTest(TableTestCases.StandardTableTestCase):
+class CustomFieldChoiceSetTableTestCase(TableTestCases.StandardTableTestCase):
     table = CustomFieldChoiceSetTable
     table = CustomFieldChoiceSetTable
 
 
 
 
-class CustomLinkTableTest(TableTestCases.StandardTableTestCase):
+class CustomLinkTableTestCase(TableTestCases.StandardTableTestCase):
     table = CustomLinkTable
     table = CustomLinkTable
 
 
 
 
-class ExportTemplateTableTest(TableTestCases.StandardTableTestCase):
+class ExportTemplateTableTestCase(TableTestCases.StandardTableTestCase):
     table = ExportTemplateTable
     table = ExportTemplateTable
 
 
 
 
-class SavedFilterTableTest(TableTestCases.StandardTableTestCase):
+class SavedFilterTableTestCase(TableTestCases.StandardTableTestCase):
     table = SavedFilterTable
     table = SavedFilterTable
 
 
 
 
-class TableConfigTableTest(TableTestCases.StandardTableTestCase):
+class TableConfigTableTestCase(TableTestCases.StandardTableTestCase):
     table = TableConfigTable
     table = TableConfigTable
 
 
 
 
-class BookmarkTableTest(TableTestCases.StandardTableTestCase):
+class BookmarkTableTestCase(TableTestCases.StandardTableTestCase):
     table = BookmarkTable
     table = BookmarkTable
 
 
     # The list view for this table lives in account.views (not extras.views),
     # The list view for this table lives in account.views (not extras.views),
@@ -37,11 +37,11 @@ class BookmarkTableTest(TableTestCases.StandardTableTestCase):
     ]
     ]
 
 
 
 
-class NotificationGroupTableTest(TableTestCases.StandardTableTestCase):
+class NotificationGroupTableTestCase(TableTestCases.StandardTableTestCase):
     table = NotificationGroupTable
     table = NotificationGroupTable
 
 
 
 
-class NotificationTableTest(TableTestCases.StandardTableTestCase):
+class NotificationTableTestCase(TableTestCases.StandardTableTestCase):
     table = NotificationTable
     table = NotificationTable
 
 
     # The list view for this table lives in account.views (not extras.views),
     # The list view for this table lives in account.views (not extras.views),
@@ -51,7 +51,7 @@ class NotificationTableTest(TableTestCases.StandardTableTestCase):
     ]
     ]
 
 
 
 
-class SubscriptionTableTest(TableTestCases.StandardTableTestCase):
+class SubscriptionTableTestCase(TableTestCases.StandardTableTestCase):
     table = SubscriptionTable
     table = SubscriptionTable
 
 
     # The list view for this table lives in account.views (not extras.views),
     # The list view for this table lives in account.views (not extras.views),
@@ -61,33 +61,33 @@ class SubscriptionTableTest(TableTestCases.StandardTableTestCase):
     ]
     ]
 
 
 
 
-class WebhookTableTest(TableTestCases.StandardTableTestCase):
+class WebhookTableTestCase(TableTestCases.StandardTableTestCase):
     table = WebhookTable
     table = WebhookTable
 
 
 
 
-class EventRuleTableTest(TableTestCases.StandardTableTestCase):
+class EventRuleTableTestCase(TableTestCases.StandardTableTestCase):
     table = EventRuleTable
     table = EventRuleTable
 
 
 
 
-class TagTableTest(TableTestCases.StandardTableTestCase):
+class TagTableTestCase(TableTestCases.StandardTableTestCase):
     table = TagTable
     table = TagTable
 
 
 
 
-class ConfigContextProfileTableTest(TableTestCases.StandardTableTestCase):
+class ConfigContextProfileTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConfigContextProfileTable
     table = ConfigContextProfileTable
 
 
 
 
-class ConfigContextTableTest(TableTestCases.StandardTableTestCase):
+class ConfigContextTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConfigContextTable
     table = ConfigContextTable
 
 
 
 
-class ConfigTemplateTableTest(TableTestCases.StandardTableTestCase):
+class ConfigTemplateTableTestCase(TableTestCases.StandardTableTestCase):
     table = ConfigTemplateTable
     table = ConfigTemplateTable
 
 
 
 
-class ImageAttachmentTableTest(TableTestCases.StandardTableTestCase):
+class ImageAttachmentTableTestCase(TableTestCases.StandardTableTestCase):
     table = ImageAttachmentTable
     table = ImageAttachmentTable
 
 
 
 
-class JournalEntryTableTest(TableTestCases.StandardTableTestCase):
+class JournalEntryTableTestCase(TableTestCases.StandardTableTestCase):
     table = JournalEntryTable
     table = JournalEntryTable

+ 1 - 1
netbox/extras/tests/test_tags.py

@@ -5,7 +5,7 @@ from dcim.models import Site
 from utilities.testing import APITestCase, create_tags
 from utilities.testing import APITestCase, create_tags
 
 
 
 
-class TaggedItemTest(APITestCase):
+class TaggedItemTestCase(APITestCase):
     """
     """
     Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
     Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
     """
     """

+ 2 - 2
netbox/extras/tests/test_utils.py

@@ -11,7 +11,7 @@ from tenancy.models import ContactGroup, TenantGroup
 from wireless.models import WirelessLANGroup
 from wireless.models import WirelessLANGroup
 
 
 
 
-class FilenameFromModelTests(TestCase):
+class FilenameFromModelTestCase(TestCase):
     def test_expected_output(self):
     def test_expected_output(self):
         cases = (
         cases = (
             (ExportTemplate, 'netbox_export_templates'),
             (ExportTemplate, 'netbox_export_templates'),
@@ -43,7 +43,7 @@ class OverwriteStyleStorage(Storage):
         return f'{file_root}_sdmmer4{file_ext}'
         return f'{file_root}_sdmmer4{file_ext}'
 
 
 
 
-class ImageUploadTests(TestCase):
+class ImageUploadTestCase(TestCase):
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         # We only need a ContentType with model="rack" for the prefix;
         # We only need a ContentType with model="rack" for the prefix;

+ 79 - 26
netbox/extras/tests/test_views.py

@@ -181,6 +181,27 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
+class CustomLinkRenderingTestCase(TestCase):
+    user_permissions = ['dcim.view_site']
+
+    def test_view_object_with_custom_link(self):
+        customlink = CustomLink(
+            name='Test',
+            link_text='FOO {{ object.name }} BAR',
+            link_url='http://example.com/?site={{ object.slug }}',
+            new_window=False
+        )
+        customlink.save()
+        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
+
+        site = Site(name='Test Site', slug='test-site')
+        site.save()
+
+        response = self.client.get(site.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn(f'FOO {site.name} BAR', str(response.content))
+
+
 class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = SavedFilter
     model = SavedFilter
 
 
@@ -349,6 +370,59 @@ class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
+class ExportTemplateExportFlowTestCase(TestCase):
+    """
+    End-to-end test for ExportTemplate invocation via a list view's ?export=<name> query param.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        Site.objects.bulk_create([
+            Site(name='Site A', slug='site-a'),
+            Site(name='Site B', slug='site-b'),
+        ])
+
+        site_type = ObjectType.objects.get_for_model(Site)
+
+        ok_template = ExportTemplate.objects.create(
+            name='Sites Export',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+            mime_type='text/plain',
+            file_extension='txt',
+        )
+        ok_template.object_types.set([site_type])
+
+        broken_template = ExportTemplate.objects.create(
+            name='Broken Export',
+            template_code='{% for obj in queryset %}{{ obj.name ',  # unterminated expression
+        )
+        broken_template.object_types.set([site_type])
+
+    def test_export_template_invocation(self):
+        self.add_permissions('dcim.view_site', 'extras.view_exporttemplate')
+        url = reverse('dcim:site_list')
+
+        response = self.client.get(f'{url}?export=Sites Export')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], 'text/plain')
+        self.assertEqual(response['Content-Disposition'], 'attachment; filename="netbox_sites.txt"')
+        # The rendered queryset reflects whatever ordering the list view applies. Assert on set
+        # membership rather than line order so the test isn't coupled to Site's natural ordering.
+        rendered_names = set(filter(None, response.content.decode().split('\n')))
+        self.assertEqual(rendered_names, {'Site A', 'Site B'})
+
+    def test_export_template_render_error_redirects(self):
+        self.add_permissions('dcim.view_site', 'extras.view_exporttemplate')
+        url = reverse('dcim:site_list')
+
+        # A broken template surfaces an exception during render; the view catches it and redirects
+        # back to the (filtered) list view rather than returning a 500.
+        response = self.client.get(f'{url}?export=Broken Export')
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(response['Location'].startswith(url))
+        self.assertNotIn('export=', response['Location'])
+
+
 class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Webhook
     model = Webhook
 
 
@@ -703,27 +777,6 @@ class JournalEntryTestCase(
         }
         }
 
 
 
 
-class CustomLinkTest(TestCase):
-    user_permissions = ['dcim.view_site']
-
-    def test_view_object_with_custom_link(self):
-        customlink = CustomLink(
-            name='Test',
-            link_text='FOO {{ object.name }} BAR',
-            link_url='http://example.com/?site={{ object.slug }}',
-            new_window=False
-        )
-        customlink.save()
-        customlink.object_types.set([ObjectType.objects.get_for_model(Site)])
-
-        site = Site(name='Test Site', slug='test-site')
-        site.save()
-
-        response = self.client.get(site.get_absolute_url(), follow=True)
-        self.assertEqual(response.status_code, 200)
-        self.assertIn(f'FOO {site.name} BAR', str(response.content))
-
-
 class SubscriptionTestCase(
 class SubscriptionTestCase(
     ViewTestCases.CreateObjectViewTestCase,
     ViewTestCases.CreateObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
@@ -887,7 +940,7 @@ class NotificationTestCase(
         return
         return
 
 
 
 
-class ScriptListViewTest(TestCase):
+class ScriptListViewTestCase(TestCase):
     user_permissions = ['extras.view_script']
     user_permissions = ['extras.view_script']
 
 
     def test_script_list_embedded_parameter(self):
     def test_script_list_embedded_parameter(self):
@@ -905,7 +958,7 @@ class ScriptListViewTest(TestCase):
         self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
         self.assertTemplateUsed(response, 'extras/inc/script_list_content.html')
 
 
 
 
-class ScriptValidationErrorTest(TestCase):
+class ScriptValidationErrorTestCase(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
     user_permissions = ['extras.view_script', 'extras.run_script']
 
 
     class TestScriptMixin:
     class TestScriptMixin:
@@ -936,7 +989,7 @@ class ScriptValidationErrorTest(TestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
-        Script.python_class = property(lambda self: ScriptValidationErrorTest.TestScriptClass)
+        Script.python_class = property(lambda self: ScriptValidationErrorTestCase.TestScriptClass)
 
 
     @tag('regression')
     @tag('regression')
     def test_script_validation_error_displays_message(self):
     def test_script_validation_error_displays_message(self):
@@ -975,7 +1028,7 @@ class ScriptValidationErrorTest(TestCase):
         self.assertEqual(len(messages), 0)
         self.assertEqual(len(messages), 0)
 
 
 
 
-class ScriptDefaultValuesTest(TestCase):
+class ScriptDefaultValuesTestCase(TestCase):
     user_permissions = ['extras.view_script', 'extras.run_script']
     user_permissions = ['extras.view_script', 'extras.run_script']
 
 
     class TestScriptClass(PythonClass):
     class TestScriptClass(PythonClass):
@@ -1005,7 +1058,7 @@ class ScriptDefaultValuesTest(TestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
-        Script.python_class = property(lambda self: ScriptDefaultValuesTest.TestScriptClass)
+        Script.python_class = property(lambda self: ScriptDefaultValuesTestCase.TestScriptClass)
 
 
     def test_default_values_are_used(self):
     def test_default_values_are_used(self):
         url = reverse('extras:script', kwargs={'pk': self.script.pk})
         url = reverse('extras:script', kwargs={'pk': self.script.pk})

+ 6 - 0
netbox/ipam/forms/filtersets.py

@@ -352,6 +352,7 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
         ),
         ),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
         FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
+        FieldSet('nat_inside_id', name=_('NAT')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@@ -397,6 +398,11 @@ class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryMode
         required=False,
         required=False,
         label=_('Assigned VM'),
         label=_('Assigned VM'),
     )
     )
+    nat_inside_id = DynamicModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label=_('NAT inside'),
+    )
     status = forms.MultipleChoiceField(
     status = forms.MultipleChoiceField(
         label=_('Status'),
         label=_('Status'),
         choices=IPAddressStatusChoices,
         choices=IPAddressStatusChoices,

+ 6 - 6
netbox/ipam/graphql/types.py

@@ -128,7 +128,7 @@ class FHRPGroupType(IPAddressesMixin, PrimaryObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
 class FHRPGroupAssignmentType(BaseObjectType):
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
     group: Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='interface')
     def interface(self) -> Annotated[
     def interface(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
         | Annotated['VMInterfaceType', strawberry.lazy('virtualization.graphql.types')],
@@ -153,7 +153,7 @@ class IPAddressType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
     tunnel_terminations: list[Annotated['TunnelTerminationType', strawberry.lazy('vpn.graphql.types')]]
     tunnel_terminations: list[Annotated['TunnelTerminationType', strawberry.lazy('vpn.graphql.types')]]
     services: list[Annotated['ServiceType', strawberry.lazy('ipam.graphql.types')]]
     services: list[Annotated['ServiceType', strawberry.lazy('ipam.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='assigned_object')
     def assigned_object(self) -> Annotated[
     def assigned_object(self) -> Annotated[
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['InterfaceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
         | Annotated['FHRPGroupType', strawberry.lazy('ipam.graphql.types')]
@@ -190,7 +190,7 @@ class PrefixType(ContactsMixin, BaseIPAddressFamilyType, PrimaryObjectType):
     vlan: Annotated['VLANType', strawberry.lazy('ipam.graphql.types')] | None
     vlan: Annotated['VLANType', strawberry.lazy('ipam.graphql.types')] | None
     role: Annotated['RoleType', strawberry.lazy('ipam.graphql.types')] | None
     role: Annotated['RoleType', strawberry.lazy('ipam.graphql.types')] | None
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='scope')
     def scope(self) -> Annotated[
     def scope(self) -> Annotated[
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         Annotated['LocationType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['RegionType', strawberry.lazy('dcim.graphql.types')]
@@ -252,7 +252,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
     ports: list[int]
     ports: list[int]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
     ipaddresses: list[Annotated['IPAddressType', strawberry.lazy('ipam.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='parent')
     def parent(self) -> Annotated[
     def parent(self) -> Annotated[
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         Annotated['DeviceType', strawberry.lazy('dcim.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]
@@ -291,7 +291,7 @@ class VLANType(PrimaryObjectType):
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     interfaces_as_tagged: list[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
     vminterfaces_as_tagged: list[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='qinq_svlan')
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
     def qinq_svlan(self) -> Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None:
         return self.qinq_svlan
         return self.qinq_svlan
 
 
@@ -309,7 +309,7 @@ class VLANGroupType(OrganizationalObjectType):
     total_vlan_ids: BigInt
     total_vlan_ids: BigInt
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
     tenant: Annotated['TenantType', strawberry.lazy('tenancy.graphql.types')] | None
 
 
-    @strawberry_django.field
+    @strawberry_django.field(prefetch_related='scope')
     def scope(self) -> Annotated[
     def scope(self) -> Annotated[
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         Annotated['ClusterType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]
         | Annotated['ClusterGroupType', strawberry.lazy('virtualization.graphql.types')]

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů