Ver Fonte

Merge branch 'main' into feature

Jeremy Stretch há 1 mês atrás
pai
commit
0b002c1b6e
100 ficheiros alterados com 6485 adições e 376 exclusões
  1. 45 0
      .claude/skills/README.md
  2. 217 0
      .claude/skills/add-config-param/SKILL.md
  3. 410 0
      .claude/skills/add-model-field/SKILL.md
  4. 519 0
      .claude/skills/add-model/SKILL.md
  5. 168 0
      .claude/skills/remove-config-param/SKILL.md
  6. 217 0
      .claude/skills/remove-model-field/SKILL.md
  7. 194 0
      .claude/skills/remove-model/SKILL.md
  8. 92 0
      .claude/skills/run-tests/SKILL.md
  9. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  10. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  11. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  12. 7 9
      .github/PULL_REQUEST_TEMPLATE.md
  13. 156 63
      .github/workflows/ci.yml
  14. 52 19
      .gitignore
  15. 319 0
      AGENTS.md
  16. 1 87
      CLAUDE.md
  17. 4 4
      CONTRIBUTING.md
  18. 909 12
      contrib/openapi.json
  19. 4 0
      docs/best-practices/performance-handbook.md
  20. 0 2
      docs/configuration/error-reporting.md
  21. 10 0
      docs/configuration/graphql-api.md
  22. 15 1
      docs/configuration/miscellaneous.md
  23. 3 3
      docs/configuration/security.md
  24. 51 1
      docs/configuration/system.md
  25. 33 0
      docs/customization/custom-scripts.md
  26. 12 0
      docs/development/getting-started.md
  27. 6 0
      docs/features/context-data.md
  28. 19 2
      docs/features/devices-cabling.md
  29. 7 1
      docs/features/facilities.md
  30. 3 0
      docs/installation/1-postgresql.md
  31. 28 1
      docs/installation/index.md
  32. 15 1
      docs/installation/upgrading.md
  33. 57 1
      docs/integrations/rest-api.md
  34. BIN
      docs/media/installation/netbox_application_stack.png
  35. BIN
      docs/media/installation/upgrade_paths.png
  36. 43 0
      docs/models/core/objectchange.md
  37. 26 0
      docs/models/core/objecttype.md
  38. 3 3
      docs/models/dcim/cablebundle.md
  39. 17 3
      docs/models/dcim/macaddress.md
  40. 2 1
      docs/models/dcim/moduletype.md
  41. 16 4
      docs/models/dcim/moduletypeprofile.md
  42. 17 3
      docs/models/extras/configcontextprofile.md
  43. 7 0
      docs/models/ipam/iprange.md
  44. 10 0
      docs/models/ipam/vlangroup.md
  45. 19 0
      docs/models/users/group.md
  46. 50 0
      docs/models/users/objectpermission.md
  47. 6 2
      docs/models/users/ownergroup.md
  48. 51 0
      docs/models/users/token.md
  49. 47 0
      docs/models/users/user.md
  50. 2 1
      docs/plugins/development/index.md
  51. 56 9
      docs/plugins/development/permissions.md
  52. 32 9
      docs/plugins/installation.md
  53. 8 7
      docs/plugins/removal.md
  54. 83 0
      docs/release-notes/version-4.6.md
  55. 6 0
      mkdocs.yml
  56. 16 6
      netbox/circuits/forms/model_forms.py
  57. 2 2
      netbox/circuits/graphql/types.py
  58. 8 0
      netbox/circuits/models/circuits.py
  59. 3 3
      netbox/circuits/tables/circuits.py
  60. 24 0
      netbox/circuits/tests/query_counts.json
  61. 12 12
      netbox/circuits/tests/test_api.py
  62. 43 0
      netbox/circuits/tests/test_forms.py
  63. 20 0
      netbox/circuits/tests/test_models.py
  64. 92 0
      netbox/circuits/tests/test_signals.py
  65. 11 11
      netbox/circuits/tests/test_tables.py
  66. 18 4
      netbox/circuits/tests/test_views.py
  67. 32 3
      netbox/circuits/ui/panels.py
  68. 1 1
      netbox/core/apps.py
  69. 29 1
      netbox/core/checks.py
  70. 27 12
      netbox/core/data_backends.py
  71. 17 0
      netbox/core/filtersets.py
  72. 4 1
      netbox/core/jobs.py
  73. 1 1
      netbox/core/management/commands/nbshell.py
  74. 16 0
      netbox/core/signals.py
  75. 8 0
      netbox/core/tests/query_counts.json
  76. 59 16
      netbox/core/tests/test_api.py
  77. 3 3
      netbox/core/tests/test_changelog.py
  78. 52 3
      netbox/core/tests/test_data_backends.py
  79. 50 0
      netbox/core/tests/test_filtersets.py
  80. 387 0
      netbox/core/tests/test_jobs.py
  81. 79 0
      netbox/core/tests/test_management_command_coverage.py
  82. 317 0
      netbox/core/tests/test_management_commands.py
  83. 2 2
      netbox/core/tests/test_models.py
  84. 547 0
      netbox/core/tests/test_signals.py
  85. 5 5
      netbox/core/tests/test_tables.py
  86. 49 6
      netbox/core/tests/test_views.py
  87. 3 2
      netbox/core/views.py
  88. 14 1
      netbox/dcim/api/serializers_/devicetypes.py
  89. 5 0
      netbox/dcim/api/serializers_/rackunits.py
  90. 80 0
      netbox/dcim/filtersets.py
  91. 8 1
      netbox/dcim/forms/filtersets.py
  92. 2 1
      netbox/dcim/forms/model_forms.py
  93. 63 18
      netbox/dcim/graphql/types.py
  94. 15 0
      netbox/dcim/migrations/0234_cablepath_nodes_index.py
  95. 35 0
      netbox/dcim/migrations/0235_cabletermination_circuit_site_cache.py
  96. 99 0
      netbox/dcim/migrations/0236_moduletype_component_counts.py
  97. 48 6
      netbox/dcim/models/cables.py
  98. 60 1
      netbox/dcim/models/device_components.py
  99. 36 2
      netbox/dcim/models/modules.py
  100. 6 0
      netbox/dcim/signals.py

+ 45 - 0
.claude/skills/README.md

@@ -0,0 +1,45 @@
+# .claude/
+
+Project-local Claude Code configuration for NetBox.
+
+The tool-agnostic content layer for this repo is [`AGENTS.md`](../AGENTS.md) at the repo root, with its `CLAUDE.md` shim. This `.claude/` directory is the Claude-specific action layer that complements `AGENTS.md` with project-local skills, slash commands, and per-developer settings.
+
+## Layout
+
+- `skills/` — Project-local Claude Code skills. Each skill is its own subdirectory containing a `SKILL.md` describing what it does and when to use it. Use this for repo-specific procedures.
+- `commands/` — Project-local slash commands. One Markdown file per command: `commands/<command-name>.md`. Use this for `/foo` shortcuts that only make sense in this repo.
+- `settings.local.json` — Per-developer Claude Code settings (tool permissions, MCP server paths, IDE preferences). **Never committed** — this filename is in the repo's `.gitignore`.
+
+## When to add a skill (vs. inlining in AGENTS.md or promoting upstream)
+
+Add a skill here when:
+
+- The procedure is repo-specific (it would not be useful in other NBL repos as-is).
+- The procedure is non-trivial (more than a one-line note that fits naturally inside `AGENTS.md`).
+- The procedure is a recipe an agent or engineer might re-run, not a one-off.
+
+## When to add a slash command
+
+Add a command here when:
+
+- The action is something you find yourself typing the same prompt for repeatedly.
+- The repo has a non-obvious workflow that benefits from a shortcut.
+
+## Conventions
+
+- Skill and command names use `lowercase-kebab-case`, matching the [folder naming convention in `AGENTS.md`](../AGENTS.md).
+- Each skill directory has a `SKILL.md` (the entry point); supporting files (references, examples, sample data) live alongside it inside the skill's directory.
+- Each command is a single Markdown file named for the slash command: `commands/<command-name>.md`.
+- Skills and commands document *why* they make the choices they do — the rationale is more durable than the bare instruction.
+
+## How to add your first skill
+
+1. Pick a kebab-case name describing the action: e.g., `parse-linear-issues`, `render-delivery-row`.
+2. `mkdir .claude/skills/<skill-name>/` and create `SKILL.md` inside it.
+3. The `SKILL.md` opens with a short YAML-ish header (name, description, version) and then the prompt content.
+4. Open a PR — the new directory and its `SKILL.md` are tracked once committed.
+
+## References
+
+- [`AGENTS.md`](../AGENTS.md) — this repo's primary agent-context file (open standard).
+- [Claude Code skills documentation](https://docs.claude.com/en/docs/claude-code/skills) — what a `SKILL.md` looks like and how Claude Code resolves them.

+ 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/`

+ 410 - 0
.claude/skills/add-model-field/SKILL.md

@@ -0,0 +1,410 @@
+---
+name: add-model-field
+description: Step-by-step checklist for adding a new field to an existing NetBox model, covering all required touch points (model, migration, validation, serializer, forms, filterset, table, panel/template, search, GraphQL, tests, docs). Use when the user asks to add a field or attribute to an existing model.
+---
+
+# Adding a Field to an Existing NetBox Model
+
+Adding a field to an existing model touches many files. The scope depends on the field type and how it will be used. Work through the checklist below in order — each section builds on the previous.
+
+## Before You Start
+
+Determine upfront:
+- **Field type**: scalar (CharField, IntegerField, etc.), FK/M2M, GenericForeignKey, or a special type like JSONField
+- **Nullable/optional?** Most new fields should be `blank=True, null=True` unless there's a strong reason otherwise
+- **Searchable?** Should it appear in global search results?
+- **Filterable?** Should it be exposed in the FilterSet?
+- **Displayable in list view?** Should it be a column in the object table?
+- **Displayable in detail view?** Should it appear in the detail panel?
+
+## 1. Add the Field to the Model
+
+**File:** `netbox/<app>/models/<module>.py`
+
+```python
+class MyModel(PrimaryModel):
+    # ... existing fields ...
+    new_field = models.CharField(
+        verbose_name=_('new field'),
+        max_length=100,
+        blank=True,
+    )
+    # FK example:
+    related_thing = models.ForeignKey(
+        to='app.RelatedModel',
+        on_delete=models.PROTECT,
+        related_name='my_models',
+        blank=True,
+        null=True,
+    )
+```
+
+The `related_name` of a ForeignKey field should generally be the verbose form of the related model's name (e.g. `books` rather than the default `book_set`).
+
+**Special cases:**
+
+- **GenericForeignKey**: If this is a non-unique GFK, add a composite index in `Meta`:
+  ```python
+  class Meta:
+      indexes = (
+          models.Index(fields=('object_type', 'object_id')),
+      )
+  ```
+
+- **`clone_fields`**: If the field should be pre-filled when cloning an object, add it to `clone_fields` on the model class:
+  ```python
+  clone_fields = ('existing_field', 'new_field')
+  ```
+
+- **Validation**: If the new field introduces cross-field constraints, add logic to `clean()`:
+  ```python
+  def clean(self):
+      super().clean()
+      if self.new_field and not self.related_field:
+          raise ValidationError({'new_field': _('...')})
+  ```
+
+## 2. Generate the Migration
+
+**Do NOT write migrations manually.** Tell the user to run:
+
+```bash
+python netbox/manage.py makemigrations <app> -n <short_descriptive_name> --no-header
+```
+
+Set `DEVELOPER = True` in `configuration.py` if the command is blocked.
+
+For FK fields, also run:
+```bash
+python netbox/manage.py migrate
+```
+before continuing, so the DB is in sync for manual testing.
+
+## 3. Update the API Serializer
+
+The serializer lives under `netbox/<app>/api/serializers_/` (note the trailing underscore — it's a directory of submodules star-imported by `serializers.py`). Find the submodule that owns the model and edit the serializer there.
+
+- **Simple field**: just add the field name to `fields` in `Meta`:
+  ```python
+  class Meta:
+      fields = [..., 'new_field', ...]
+  ```
+
+- **FK field**: add a single serializer field with `nested=True`. NetBox does not use a separate `_id` companion field — the framework accepts a primary key (or brief object) when writing:
+  ```python
+  related_thing = RelatedThingSerializer(
+      nested=True,
+      required=False,
+      allow_null=True,
+  )
+  # Add 'related_thing' to Meta.fields
+  ```
+
+- **`brief_fields`**: only add to `brief_fields` if the field is truly essential for compact/nested representations.
+
+## 4. Update Forms
+
+There are typically up to four forms to update. Find them under `netbox/<app>/forms/`.
+
+### 4a. Model form (create/edit) — `model_forms.py`
+
+Add the field to the `fieldsets` tuple and to `Meta.fields`:
+
+```python
+class MyModelForm(PrimaryModelForm):
+    fieldsets = (
+        FieldSet('name', 'new_field', 'related_thing', name=_('My Model')),
+        ...
+    )
+    class Meta:
+        model = MyModel
+        fields = ('name', 'new_field', 'related_thing', ...)
+```
+
+For FK fields, use `DynamicModelChoiceField`:
+```python
+related_thing = DynamicModelChoiceField(
+    queryset=RelatedModel.objects.all(),
+    required=False,
+)
+```
+
+### 4b. Bulk edit form — `bulk_edit.py`
+
+Add the field as optional (so it can be blanked):
+```python
+new_field = forms.CharField(required=False)
+# or for FK:
+related_thing = DynamicModelChoiceField(queryset=..., required=False)
+nullable_fields = ('new_field', 'related_thing')  # if it can be set to null
+```
+Add to `fieldsets` and `Meta.fields` here too.
+
+### 4c. Bulk import form — `bulk_import.py`
+
+If the field should be importable via CSV, add it to the import form:
+```python
+class MyModelImportForm(NetBoxModelImportForm):
+    new_field = forms.CharField(required=False)
+    class Meta:
+        model = MyModel
+        fields = ('name', 'new_field', ...)
+```
+
+### 4d. Filter form — `filtersets.py` (the forms version)
+
+The base class should match the model's base (`PrimaryModelFilterSetForm`, `OrganizationalModelFilterSetForm`, `NestedGroupModelFilterSetForm`, or `NetBoxModelFilterSetForm`). Add the new entries to the existing `fieldsets` and declare the filter field:
+
+```python
+class MyModelFilterForm(PrimaryModelFilterSetForm):
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('new_field', 'related_thing_id', name=_('Attributes')),
+    )
+    new_field = forms.CharField(required=False)
+    related_thing_id = DynamicModelMultipleChoiceField(
+        queryset=RelatedModel.objects.all(),
+        required=False,
+        label=_('Related Thing'),
+    )
+```
+
+## 5. Update the FilterSet
+
+**File:** `netbox/<app>/filtersets.py`
+
+- **Simple scalar field**: add to `Meta.fields` if a basic exact/contains filter suffices.
+- **FK field**: add both `<field>` (name lookup) and `<field>_id` (PK lookup) explicitly — do not rely on `Meta.fields` to generate them:
+
+```python
+class MyModelFilterSet(PrimaryModelFilterSet):
+    related_thing = django_filters.ModelMultipleChoiceFilter(
+        field_name='related_thing__name',
+        queryset=RelatedModel.objects.all(),
+        to_field_name='name',
+        label=_('Related thing (name)'),
+    )
+    related_thing_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RelatedModel.objects.all(),
+        label=_('Related thing (ID)'),
+    )
+
+    class Meta:
+        model = MyModel
+        fields = ('id', 'name', 'new_field', ...)  # add new_field here for simple fields
+```
+
+If the field should be searchable from the search box (`q=`), add it to the `search()` method:
+```python
+def search(self, queryset, name, value):
+    return queryset.filter(
+        Q(name__icontains=value) |
+        Q(new_field__icontains=value) |  # add here
+        ...
+    )
+```
+
+## 6. Update the Table
+
+**File:** `netbox/<app>/tables/<module>.py`
+
+- **Simple field**: just add the field name to `Meta.fields`. Add to `default_columns` if it should show by default.
+- **FK field** (linking to another object):
+  ```python
+  related_thing = tables.Column(linkify=True)
+  ```
+  Add `related_thing` to both `Meta.fields` and `default_columns` if appropriate.
+- **Choice field**: display just works if the model uses `get_<field>_display()`; no custom column needed.
+- **Traversed FK** (field accessed through another relation):
+  ```python
+  related_thing = tables.Column(
+      accessor=tables.A('some_fk__related_thing'),
+      linkify=True,
+  )
+  ```
+
+## 7. Update the Detail View Panel
+
+The detail view display is controlled by a panel class (not an HTML template), defined under `netbox/<app>/ui/panels.py`.
+
+Find the panel for the model and add a new attribute declaration:
+
+```python
+from netbox.ui import attrs, panels
+
+class MyModelPanel(panels.ObjectAttributesPanel):
+    existing_field = attrs.TextAttr('existing_field')
+    new_field = attrs.TextAttr('new_field')               # simple text
+    related_thing = attrs.RelatedObjectAttr('related_thing', linkify=True)  # FK
+    status = attrs.ChoiceAttr('status')                   # choice field with badge
+    is_active = attrs.BooleanAttr('is_active')            # boolean
+    color = attrs.ColorAttr('color')                      # color swatch
+```
+
+**Available attr types** (from `netbox.ui.attrs`):
+
+| Class | Use for |
+|---|---|
+| `TextAttr` | Plain text / CharField |
+| `NumericAttr` | Numbers, optionally with a unit |
+| `ChoiceAttr` | Choice fields (renders a colored badge) |
+| `BooleanAttr` | Boolean fields |
+| `ColorAttr` | Color hex fields |
+| `RelatedObjectAttr` | Direct ForeignKey |
+| `NestedObjectAttr` | ForeignKey on a nested/hierarchical model (e.g. region.parent) |
+| `RelatedObjectListAttr` | ManyToMany or reverse FK list |
+| `GenericForeignKeyAttr` | GenericForeignKey |
+| `DateTimeAttr` | DateTimeField |
+| `TimezoneAttr` | Timezone fields |
+| `AddressAttr` | Address text (optionally with map link) |
+| `TemplatedAttr` | Custom per-field HTML template |
+
+If the model uses a legacy HTML template (under `netbox/templates/<app>/`) rather than a declarative panel, add a `<tr>` row to the relevant `<table>` in that template instead.
+
+## 8. Update the SearchIndex (if applicable)
+
+**File:** `netbox/<app>/search.py`
+
+If the new field should be indexed for global search, add it to the model's `SearchIndex`:
+
+```python
+@register_search
+class MyModelIndex(SearchIndex):
+    model = models.MyModel
+    fields = (
+        ('name', 100),
+        ('new_field', 300),   # add here with an appropriate weight
+        ('description', 500),
+        ('comments', 5000),
+    )
+```
+
+Weight guide: lower = higher search priority. Name fields ~100, short descriptors ~300–500, long-form comments ~5000.
+
+## 9. Update GraphQL
+
+### Filter — `graphql/filters.py`
+
+Add a filter field to the model's `Filter` class:
+
+```python
+@strawberry_django.filter_type(models.MyModel, lookups=True)
+class MyModelFilter(PrimaryModelFilter):
+    # simple field (lookups=True auto-generates eq/icontains/etc.)
+    new_field: StrFilterLookup[str] | None = strawberry_django.filter_field()
+
+    # FK field:
+    related_thing: Annotated['RelatedThingFilter', strawberry.lazy('<app>.graphql.filters')] | None = strawberry_django.filter_field()
+    related_thing_id: ID | None = strawberry_django.filter_field()
+```
+
+### Type — `graphql/types.py`
+
+For simple fields, `fields='__all__'` on the type decorator will pick up the new field automatically. No change needed unless:
+
+- The field is in an `exclude` list on the type — remove it.
+- The field requires a custom type annotation (e.g. a lazy FK reference or a special scalar):
+  ```python
+  @strawberry_django.type(models.MyModel, fields='__all__', ...)
+  class MyModelType(PrimaryObjectType):
+      related_thing: Annotated['RelatedThingType', strawberry.lazy('<app>.graphql.types')] | None
+  ```
+
+> **Prefetch null failures:** If GraphQL unit tests fail citing null values on a non-nullable field, change the field definition to use `select_related`:
+> ```python
+> related_thing: ... = strawberry_django.field(select_related=['related_thing'])
+> ```
+
+## 10. Write Tests
+
+### FilterSet tests — `tests/test_filtersets.py`
+
+Add test methods for any new FilterSet fields:
+
+```python
+def test_new_field(self):
+    params = {'new_field': ['value1', 'value2']}
+    self.assertEqual(self.filterset(params, self.queryset).qs.count(), expected)
+
+def test_related_thing(self):
+    # Test both name and _id variants
+    related = RelatedModel.objects.filter(...)
+    params = {'related_thing_id': [related[0].pk]}
+    self.assertEqual(self.filterset(params, self.queryset).qs.count(), expected)
+    params = {'related_thing': [related[0].name]}
+    self.assertEqual(self.filterset(params, self.queryset).qs.count(), expected)
+```
+
+Ensure `setUpTestData` creates test objects with diverse values for the new field.
+
+### API tests — `tests/test_api.py`
+
+- Update `setUpTestData` to populate the new field in test instances.
+- Update `create_data` and (if applicable) `bulk_update_data` to include the new field.
+- If the field is filterable via the API, add a `test_list_objects_by_<field>` test.
+
+### View tests — `tests/test_views.py`
+
+- Update `form_data` in `setUpTestData` to include the new field.
+- Update `bulk_edit_data` if the field is bulk-editable.
+- Update `csv_data` if the field is importable.
+
+### Model tests — `tests/test_models.py` (if validation was added)
+
+Add a test for any custom `clean()` logic:
+
+```python
+def test_clean_new_field_validation(self):
+    instance = MyModel(new_field='invalid_value', ...)
+    with self.assertRaises(ValidationError):
+        instance.clean()
+```
+
+## 11. Update Documentation
+
+**File:** `docs/models/<app>/<modelname>.md`
+
+Add the new field to the model's documentation page. Include:
+- The field name and description
+- Valid values (for choice fields)
+- Any constraints or dependencies
+
+## Summary Checklist
+
+| # | File(s) | Action |
+|---|---|---|
+| 1 | `models/<module>.py` | Add field; add to `clone_fields`; add `clean()` validation |
+| 2 | (user runs) | `makemigrations <app> -n <name> --no-header` |
+| 3 | `api/serializers_/<module>.py` | Add field to `fields`; for FK use a single `Serializer(nested=True)` field (no `_id` companion) |
+| 4a | `forms/model_forms.py` | Add to `fieldsets` and `Meta.fields` |
+| 4b | `forms/bulk_edit.py` | Add as optional; add to `nullable_fields` if nullable |
+| 4c | `forms/bulk_import.py` | Add if CSV-importable |
+| 4d | `forms/filtersets.py` | Add filter field and to `fieldsets` |
+| 5 | `filtersets.py` | Add to FilterSet; add FK + FK_id pair; update `search()` |
+| 6 | `tables/<module>.py` | Add column; add to `Meta.fields`; update `default_columns` |
+| 7 | `<app>/ui/panels.py` | Add attr to the model's panel class |
+| 8 | `search.py` | Add to SearchIndex `fields` tuple with appropriate weight |
+| 9 | `graphql/filters.py`, `types.py` | Add filter field; update type if excluded or needs custom annotation |
+| 10 | `tests/test_*.py` | Update filterset, API, view, and model tests |
+| 11 | `docs/models/<app>/<model>.md` | Document the new field |
+
+## Common Gotchas
+
+- **FilterSets need explicit `_id` variants for FK fields** — `Meta.fields` does not auto-generate them. (This is FilterSet-only — API serializers do **not** add a parallel `_id` field; see below.)
+- **Serializer FK fields use `nested=True`, not a parallel `_id`.** Older code that defines both `foo = NestedFooSerializer(read_only=True)` and `foo_id = serializers.PrimaryKeyRelatedField(...)` is the legacy pattern; new code uses a single `foo = FooSerializer(nested=True, ...)` field.
+- **Migrations must be generated, not written manually.** If `makemigrations` is blocked, ensure `DEVELOPER = True` is set in `configuration.py`.
+- **List views and API serializers don't need manual `prefetch_related()`** — this is handled dynamically. Only add explicit prefetches in a viewset if required for a custom endpoint.
+- **`clone_fields` must be declared explicitly** on the model. Fields not in this list are not copied when cloning an object.
+- **`brief_fields` on serializers is explicit** — just listing a field in `Meta.fields` does not include it in brief/nested representations.
+- **Panel attrs, not HTML templates** — new models use `ObjectAttributesPanel` subclasses in `<app>/ui/panels.py`. Only fall back to editing `templates/<app>/` HTML files if the model predates the declarative layout system.
+- **GraphQL `fields='__all__'`** picks up simple new fields automatically; only explicit overrides needed for FKs, excluded fields, or special scalars.
+- **No `ruff format`** on existing files — use `ruff check` only.
+
+## References
+
+- Real example (adding FK filter field): `git show 87b17ff26` — adds `profile`/`profile_id` to the Module filterset, filter form, table, template, and tests
+- Real example (adding a JSONField): `git show 5f802bb18` — adds `choice_colors` to CustomFieldChoiceSet across model, forms, filterset, serializer, GraphQL, and tests
+- Panel attrs reference: `netbox/netbox/ui/attrs.py`
+- Panel classes: `netbox/<app>/ui/panels.py`
+- Base filterset classes: `netbox/netbox/filtersets.py`
+- Contributing guide: `docs/development/extending-models.md`

+ 519 - 0
.claude/skills/add-model/SKILL.md

@@ -0,0 +1,519 @@
+---
+name: add-model
+description: Step-by-step guide for adding a new model to NetBox, including all required components (model, filterset, serializer, views, forms, tables, GraphQL, tests, docs, navigation). Use when the user asks to add a new model or object type to NetBox.
+---
+
+# Adding a New Model to NetBox
+
+Adding a model requires wiring up ~12 components. Work through them in order — each builds on the previous. If the user hasn't specified which app to place the model in, ask first.
+
+## 0. Before You Start
+
+Decide on:
+- **App**: which existing app owns this model (`dcim`, `ipam`, `extras`, etc.)
+- **Base class**: see the hierarchy below
+- **URL slug**: the kebab-case name used in URLs (e.g. `virtual-chassis`)
+- **Model name**: PascalCase (e.g. `VirtualChassis`)
+- **Verbose names**: for `Meta.verbose_name` / `verbose_name_plural`
+
+### Base Class Hierarchy
+
+| Class | Use when                                                                            |
+|---|-------------------------------------------------------------------------------------|
+| `PrimaryModel` | Real infrastructure objects with description, comments, and owner. Most new models. |
+| `OrganizationalModel` | Purely organizational/grouping objects (roles, types, categories).                  |
+| `NestedGroupModel` | Hierarchical tree objects (regions, locations). Uses MPTT.                          |
+| `ChangeLoggedModel` | Lightweight ancillary objects; no custom fields, tags, etc.                         |
+| `AdminModel` | Administrative resources (no change-logging in the user-facing changelog).          |
+| `NetBoxModel` | Direct subclass of the feature set — use only when no other class fits.             |
+
+All of these live in `netbox/netbox/models/__init__.py`. The remainder of this skill assumes `PrimaryModel`; substitute the matching `Organizational…` / `NestedGroup…` / `ChangeLogged…` base classes (filterset, form, table, serializer, GraphQL) where appropriate.
+
+## 1. Define the Model
+
+**File:** `netbox/<app>/models/<module>.py` (or `models.py` for smaller apps)
+
+```python
+class MyModel(PrimaryModel):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        db_collation='natural_sort',  # for alphabetic-aware sorting
+    )
+    some_fk = models.ForeignKey(
+        to='app.RelatedModel',
+        on_delete=models.PROTECT,
+        related_name='my_models',
+        blank=True,
+        null=True,
+    )
+
+    class Meta:
+        ordering = ['name']
+        verbose_name = _('my model')
+        verbose_name_plural = _('my models')
+
+    def __str__(self):
+        return self.name
+```
+
+- Add the model to `__all__` in the models module's `__init__.py`.
+- `db_collation='natural_sort'` on name fields enables natural sort order; omit if not needed.
+- Use `models.PROTECT` for FK `on_delete` unless cascade deletion is explicitly desired.
+- `PrimaryModel` already provides `description`, `comments`, and `owner` — don't redeclare them.
+
+**Do NOT run `makemigrations` yourself.** Tell the user to run the following when finished:
+
+```bash
+python netbox/manage.py makemigrations
+```
+
+## 2. Define Field Choices (if needed)
+
+**File:** `netbox/<app>/choices.py`
+
+```python
+class MyModelStatusChoices(ChoiceSet):
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
+
+    CHOICES = [
+        (STATUS_ACTIVE, _('Active'), 'blue'),
+        (STATUS_PLANNED, _('Planned'), 'cyan'),
+    ]
+```
+
+Reference with `choices=MyModelStatusChoices` on the model field and `choices=MyModelStatusChoices.CHOICES` in forms.
+
+## 3. Create the FilterSet
+
+**File:** `netbox/<app>/filtersets.py`
+
+```python
+class MyModelFilterSet(PrimaryModelFilterSet):
+    some_fk = django_filters.ModelMultipleChoiceFilter(
+        field_name='some_fk__name',
+        queryset=RelatedModel.objects.all(),
+        to_field_name='name',
+        label=_('Related model (name)'),
+    )
+    some_fk_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=RelatedModel.objects.all(),
+        label=_('Related model (ID)'),
+    )
+
+    class Meta:
+        model = MyModel
+        fields = ('id', 'name', 'description')
+```
+
+**Critical:** Always add both `<field>` (name/slug lookup) and `<field>_id` (PK lookup) for every FK. Do not rely on `Meta.fields` to auto-generate `_id` variants — it won't work correctly.
+
+Match the base class to the model: `PrimaryModelFilterSet`, `OrganizationalModelFilterSet`, `NetBoxModelFilterSet`, or `ChangeLoggedModelFilterSet`.
+
+## 4. Create Forms
+
+**File:** `netbox/<app>/forms/model_forms.py`
+
+```python
+class MyModelForm(PrimaryModelForm):
+    fieldsets = (
+        FieldSet('name', 'some_fk', name=_('My Model')),
+        FieldSet('description', 'tags', name=_('Other')),
+    )
+
+    class Meta:
+        model = MyModel
+        fields = ('name', 'some_fk', 'description', 'owner', 'comments', 'tags')
+```
+
+**File:** `netbox/<app>/forms/filtersets.py` (for the filter form)
+
+```python
+class MyModelFilterForm(PrimaryModelFilterSetForm):
+    model = MyModel
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('some_fk_id', name=_('Related')),
+    )
+    some_fk_id = DynamicModelMultipleChoiceField(
+        queryset=RelatedModel.objects.all(),
+        required=False,
+        label=_('Related Model'),
+    )
+    tag = TagFilterField(model)
+```
+
+Match the form base class to the model's base: `PrimaryModelFilterSetForm`, `OrganizationalModelFilterSetForm`, `NestedGroupModelFilterSetForm`, or `NetBoxModelFilterSetForm` (all in `netbox.forms`).
+
+### Bulk Edit Form — `netbox/<app>/forms/bulk_edit.py`
+
+```python
+class MyModelBulkEditForm(PrimaryModelBulkEditForm):
+    model = MyModel
+    description = forms.CharField(max_length=200, required=False)
+    some_fk = DynamicModelChoiceField(queryset=RelatedModel.objects.all(), required=False)
+
+    fieldsets = (
+        FieldSet('some_fk', 'description', name=_('My Model')),
+    )
+    nullable_fields = ('description', 'some_fk')
+```
+
+### Bulk Import Form — `netbox/<app>/forms/bulk_import.py`
+
+```python
+class MyModelImportForm(PrimaryModelImportForm):
+    some_fk = CSVModelChoiceField(
+        queryset=RelatedModel.objects.all(),
+        to_field_name='name',
+        required=False,
+    )
+
+    class Meta:
+        model = MyModel
+        fields = ('name', 'some_fk', 'description', 'comments', 'tags')
+```
+
+Use the matching `Primary…` / `Organizational…` / `NestedGroup…` / `NetBoxModel…` variants of `…ImportForm` and `…BulkEditForm` for non-PrimaryModel bases.
+
+Export each new form from `netbox/<app>/forms/__init__.py`.
+
+## 5. Create the Table
+
+**File:** `netbox/<app>/tables/<module>.py`
+
+```python
+class MyModelTable(PrimaryModelTable):
+    name = tables.Column(linkify=True)
+    some_fk = tables.Column(linkify=True)
+    tags = columns.TagColumn(url_name='<app>:mymodel_list')
+
+    class Meta(PrimaryModelTable.Meta):
+        model = MyModel
+        fields = ('pk', 'id', 'name', 'some_fk', 'description', 'tags', 'created', 'last_updated')
+        default_columns = ('pk', 'name', 'some_fk', 'description')
+```
+
+Use custom columns provided by NetBox where appropriate. Otherwise, export from the tables package's `__init__.py`.
+
+## 6. Add Views
+
+**File:** `netbox/<app>/views.py`
+
+Common imports:
+
+```python
+from extras.ui.panels import CustomFieldsPanel, TagsPanel
+from netbox.ui import layout
+from netbox.ui.panels import CommentsPanel
+from netbox.views import generic
+from utilities.views import register_model_view
+```
+
+```python
+@register_model_view(MyModel, 'list', path='', detail=False)
+class MyModelListView(generic.ObjectListView):
+    queryset = MyModel.objects.all()
+    table = tables.MyModelTable
+    filterset = filtersets.MyModelFilterSet
+    filterset_form = forms.MyModelFilterForm
+
+@register_model_view(MyModel)
+class MyModelView(generic.ObjectView):
+    queryset = MyModel.objects.all()
+    template_name = 'generic/object.html'  # opt out of model-specific template lookup
+    layout = layout.SimpleLayout(
+        left_panels=[panels.MyModelPanel(), TagsPanel(), CustomFieldsPanel()],
+        right_panels=[CommentsPanel()],
+    )
+
+@register_model_view(MyModel, 'add', detail=False)
+@register_model_view(MyModel, 'edit')
+class MyModelEditView(generic.ObjectEditView):
+    queryset = MyModel.objects.all()
+    form = forms.MyModelForm
+
+@register_model_view(MyModel, 'delete')
+class MyModelDeleteView(generic.ObjectDeleteView):
+    queryset = MyModel.objects.all()
+
+@register_model_view(MyModel, 'bulk_import', path='import', detail=False)
+class MyModelBulkImportView(generic.BulkImportView):
+    queryset = MyModel.objects.all()
+    model_form = forms.MyModelImportForm
+
+@register_model_view(MyModel, 'bulk_edit', path='edit', detail=False)
+class MyModelBulkEditView(generic.BulkEditView):
+    queryset = MyModel.objects.all()
+    filterset = filtersets.MyModelFilterSet
+    table = tables.MyModelTable
+    form = forms.MyModelBulkEditForm
+
+@register_model_view(MyModel, 'bulk_delete', path='delete', detail=False)
+class MyModelBulkDeleteView(generic.BulkDeleteView):
+    queryset = MyModel.objects.all()
+    filterset = filtersets.MyModelFilterSet
+    table = tables.MyModelTable
+```
+
+`path='import'`/`'edit'`/`'delete'` keep URLs short and match existing apps. If the model has a `name` field amenable to find/replace, also register a `bulk_rename` view (`generic.BulkRenameView`, `path='rename'`).
+
+Define `MyModelPanel` as an `ObjectAttributesPanel` subclass in `netbox/<app>/ui/panels.py` (see `netbox/dcim/ui/panels.py` for examples and the field summary in `add-model-field`).
+
+## 7. Add URL Routes
+
+**File:** `netbox/<app>/urls.py`
+
+```python
+from utilities.urls import get_model_urls
+
+urlpatterns = [
+    # ...existing routes...
+    path('my-models/', include(get_model_urls('<app>', 'mymodel', detail=False))),
+    path('my-models/<int:pk>/', include(get_model_urls('<app>', 'mymodel'))),
+]
+```
+
+`get_model_urls()` auto-generates routes for all registered views. `detail=False` covers the list/create routes; the second `path` covers detail/edit/delete routes.
+
+## 8. REST API
+
+### Serializer
+
+Each app has a `netbox/<app>/api/serializers_/` package (note the trailing underscore — it's a directory). Add a new module like `mymodel.py` and re-export from `serializers_/__init__.py` (`netbox/<app>/api/serializers.py` star-imports each submodule).
+
+```python
+class MyModelSerializer(PrimaryModelSerializer):
+    some_fk = RelatedModelSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = MyModel
+        fields = [
+            'id', 'url', 'display_url', 'display',
+            'name', 'some_fk',
+            'description', 'owner', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+```
+
+NetBox serializers use a single FK field with `nested=True` — no separate `_id` companion. Pass `nested=True` when the related serializer is referenced by another serializer; the framework renders it as a brief representation when reading and accepts a primary key (or brief object) when writing. Match the base class to the model: `PrimaryModelSerializer`, `OrganizationalModelSerializer`, `NestedGroupModelSerializer`, `NetBoxModelSerializer`.
+
+### ViewSet
+
+**File:** `netbox/<app>/api/views.py`
+
+```python
+class MyModelViewSet(NetBoxModelViewSet):
+    queryset = MyModel.objects.all()
+    serializer_class = serializers.MyModelSerializer
+    filterset_class = filtersets.MyModelFilterSet
+```
+
+Skip `prefetch_related()` on the queryset — `NetBoxModelViewSet` resolves prefetches dynamically based on the serializer.
+
+### API URL Route
+
+**File:** `netbox/<app>/api/urls.py`
+
+```python
+router.register('my-models', views.MyModelViewSet)
+```
+
+## 9. GraphQL
+
+### Filter
+
+**File:** `netbox/<app>/graphql/filters.py`
+
+```python
+@strawberry_django.filter_type(models.MyModel, lookups=True)
+class MyModelFilter(PrimaryModelFilter):
+    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
+    some_fk: Annotated['RelatedModelFilter', strawberry.lazy('<app>.graphql.filters')] | None = strawberry_django.filter_field()
+    some_fk_id: ID | None = strawberry_django.filter_field()
+```
+
+Add `'MyModelFilter'` to `__all__` at the top of the file.
+
+### Type
+
+**File:** `netbox/<app>/graphql/types.py`
+
+```python
+@strawberry_django.type(
+    models.MyModel,
+    fields='__all__',
+    filters=MyModelFilter,
+    pagination=True,
+)
+class MyModelType(PrimaryObjectType):
+    some_fk: Annotated['RelatedModelType', strawberry.lazy('<app>.graphql.types')] | None
+```
+
+Add `'MyModelType'` to `__all__`.
+
+### Schema
+
+**File:** `netbox/<app>/graphql/schema.py`
+
+```python
+@strawberry.type
+class MyAppQuery:
+    # ...existing fields...
+    my_model: MyModelType = strawberry_django.field()
+    my_model_list: list[MyModelType] = strawberry_django.field()
+```
+
+> **Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. Fix by using `= strawberry_django.field(select_related=['some_fk'])` instead.
+
+## 10. Register in Search
+
+**File:** `netbox/<app>/search.py`
+
+```python
+@register_search
+class MyModelIndex(SearchIndex):
+    model = models.MyModel
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('some_fk', 'description')
+```
+
+Field weights: lower = higher priority in results. Typical: name=100, description=500, comments=5000.
+
+## 11. Add Navigation Menu Entry
+
+**File:** `netbox/netbox/navigation/menu.py`
+
+Find the relevant `MenuGroup` and add:
+
+```python
+get_model_item('<app>', 'mymodel', _('My Models')),
+```
+
+The model name must be lowercase (not the URL slug). This auto-links to the list view.
+
+## 12. Add Documentation
+
+**File:** `docs/models/<app>/<modelname>.md` (filename is the lowercase model name with no separators, e.g. `virtualchassis.md`).
+
+Include at minimum:
+- A description of what the model represents
+- A `## Fields` section with a subsection per field (see `docs/models/dcim/site.md` for the canonical structure)
+
+Then register the page in two indexes:
+
+- `mkdocs.yml` — add a line under the appropriate `nav:` group (e.g. `- MyModel: 'models/<app>/mymodel.md'`)
+- `docs/development/models.md` — add to the relevant model-type list under "Models Index" (Primary, Organizational, Nested Group, etc.)
+
+There is no per-app `index.md` under `docs/models/` — `mkdocs.yml` is the single source of truth for navigation.
+
+## 13. Write Tests
+
+### API Tests
+
+**File:** `netbox/<app>/tests/test_api.py`
+
+```python
+class MyModelTest(APIViewTestCases.APIViewTestCase):
+    model = MyModel
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        # Create 3+ instances for list/bulk tests
+        my_models = (
+            MyModel(name='My Model 1', ...),
+            MyModel(name='My Model 2', ...),
+            MyModel(name='My Model 3', ...),
+        )
+        MyModel.objects.bulk_create(my_models)
+
+        cls.create_data = [
+            {'name': 'My Model 4', ...},
+            {'name': 'My Model 5', ...},
+            {'name': 'My Model 6', ...},
+        ]
+```
+
+### View Tests
+
+**File:** `netbox/<app>/tests/test_views.py`
+
+```python
+class MyModelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = MyModel
+
+    @classmethod
+    def setUpTestData(cls):
+        my_models = (
+            MyModel(name='My Model 1', ...),
+            MyModel(name='My Model 2', ...),
+            MyModel(name='My Model 3', ...),
+        )
+        MyModel.objects.bulk_create(my_models)
+
+        cls.form_data = {
+            'name': 'My Model X',
+            # all required form fields
+        }
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+        cls.csv_data = (
+            'name',
+            'My Model 4',
+            'My Model 5',
+            'My Model 6',
+        )
+```
+
+### FilterSet Tests
+
+**File:** `netbox/<app>/tests/test_filtersets.py`
+
+```python
+from utilities.testing import ChangeLoggedFilterSetTests
+
+class MyModelFilterSetTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = MyModel.objects.all()
+    filterset = MyModelFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        # Create diverse test data
+
+    def test_name(self):
+        params = {'name': ['My Model 1', 'My Model 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_some_fk(self):
+        # Test FK and FK_id filters
+```
+
+`ChangeLoggedFilterSetTests` provides standard tests for `id`, `created`, `last_updated`, `q` search, etc. Always mix it in.
+
+## Common Gotchas
+
+- **Never write migrations manually.** Always run `python netbox/manage.py makemigrations` and let Django generate them. Set `DEVELOPER = True` in `configuration.py` to enable this.
+- **FK filters need explicit `_id` variants** in FilterSets. `Meta.fields` does not auto-generate them.
+- **`manage.py` lives in `netbox/`**, not the repo root.
+- **Brief fields** in API serializers must be declared explicitly via `brief_fields` on the `Meta` class; they are used for nested representations.
+- **GraphQL null prefetch failures**: if tests fail on non-nullable fields, add `select_related=[...]` to the `strawberry_django.field()` call.
+- **Template**: by default `generic.ObjectView` auto-resolves to `<app>/<model>.html`. If you only define a panel-driven `layout`, set `template_name = 'generic/object.html'` on the view to opt out of that lookup. Add a real per-model template only when you need markup that panels can't express.
+- **Serializer FK fields**: write a single field like `some_fk = RelatedModelSerializer(nested=True, ...)` — do **not** add a separate `some_fk_id` companion. The framework accepts a PK or brief object on write.
+- **Modern pattern check**: cargo-culting older nested serializer code (`NestedFooSerializer(read_only=True)` plus `_id` field) is wrong for new code — use the `nested=True` form.
+- **`PrimaryModel`** already has `description`, `comments`, `owner`. Don't re-add them.
+- **No `ruff format`** on existing files. Use ruff check only.
+
+## References
+
+- Model base classes: `netbox/netbox/models/__init__.py`
+- Concrete example (VirtualChassis): `netbox/dcim/models/devices.py`, `netbox/dcim/filtersets.py`, `netbox/dcim/api/`, `netbox/dcim/graphql/`, `netbox/dcim/tests/`
+- Contributing guide: `docs/development/adding-models.md`
+- Navigation menu: `netbox/netbox/navigation/menu.py`

+ 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)

+ 92 - 0
.claude/skills/run-tests/SKILL.md

@@ -0,0 +1,92 @@
+---
+name: run-tests
+description: Run NetBox's Django test suite locally. Use when the user asks to run tests, run a specific test module/class/method, or verify changes pass before opening a PR.
+---
+
+# Run the NetBox test suite
+
+NetBox uses `django.test.TestCase` (not pytest). The suite is invoked via `manage.py test` from the repo root. CI runs this exact command in `.github/workflows/ci.yml`.
+
+## Canonical command
+
+From the repo root, with the venv active:
+
+```bash
+NETBOX_CONFIGURATION=netbox.configuration_testing python netbox/manage.py test netbox/ --parallel
+```
+
+`--parallel` runs test processes in parallel and is used in CI. Drop it to debug failures that only appear in parallel mode.
+
+## Prerequisites
+
+1. PostgreSQL and Redis reachable on localhost at their default ports (credentials: `netbox`/`netbox`/`netbox`).
+2. `configuration.py` in place — copy from the example and fill in DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS. This file is gitignored and must never be committed.
+3. Dependencies installed: `pip install -r requirements.txt`.
+4. `NETBOX_CONFIGURATION` set to `netbox.configuration_testing` — the test config sets `DATABASES`, `REDIS`, and `PLUGINS` appropriately.
+
+If any of these are missing, surface the gap to the user — do not silently skip.
+
+## Useful variants
+
+Run a single app's tests:
+
+```bash
+NETBOX_CONFIGURATION=netbox.configuration_testing python netbox/manage.py test dcim --parallel
+```
+
+Run a single module, class, or method (Django dotted-path target):
+
+```bash
+NETBOX_CONFIGURATION=netbox.configuration_testing python netbox/manage.py test dcim.tests.test_api
+NETBOX_CONFIGURATION=netbox.configuration_testing python netbox/manage.py test dcim.tests.test_api.RackTestCase
+NETBOX_CONFIGURATION=netbox.configuration_testing python netbox/manage.py test dcim.tests.test_api.RackTestCase.test_list_objects
+```
+
+Speed options:
+
+- `--keepdb` — skip DB rebuild between runs (safe for most iterative work)
+- `--parallel` — run tests in parallel across CPU cores (used in CI; don't combine with `--keepdb` without testing first)
+- `--failfast` — stop on first failure
+- `-v 2` — print each test name as it runs
+
+## Standard test modules per app
+
+| Module | Coverage area |
+|---|---|
+| `test_api.py` | REST API endpoints (CRUD, filtering, bulk operations) |
+| `test_filtersets.py` | FilterSet fields and query behavior |
+| `test_models.py` | Model methods, validation, constraints |
+| `test_views.py` | UI views (list, create, edit, delete, bulk actions) |
+| `test_forms.py` | Form validation |
+| `test_tables.py` | Table column rendering |
+
+Specialized modules in some apps: `test_cablepaths.py` (dcim), `test_lookups.py` (ipam).
+
+## After model changes
+
+Always generate migrations before running tests; the test DB build will fail if migrations are missing:
+
+```bash
+python netbox/manage.py makemigrations
+```
+
+Never write migrations manually — let Django generate them.
+
+## Coverage (matches CI)
+
+```bash
+coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
+coverage report --skip-covered --omit '*/migrations/*,*/tests/*'
+```
+
+## Why these choices
+
+- **Don't substitute pytest.** The suite uses `django.test.TestCase`; switching to pytest requires `pytest-django` configured against NetBox's settings, which is not set up. Run via `manage.py test` to match CI.
+- **Always set `NETBOX_CONFIGURATION`.** Without it, Django loads `configuration.py` (the production config), which likely has a different database or may not exist in dev environments.
+- **`--parallel` for full-suite runs.** CI runs parallel; running without it locally can mask race conditions (rare) and is slower on multi-core machines.
+
+## References
+
+- [`AGENTS.md`](../../../AGENTS.md) — Testing and development sections.
+- [`.github/workflows/ci.yml`](../../../.github/workflows/ci.yml) — Authoritative CI invocation.
+- [`netbox/netbox/configuration_testing.py`](../../../netbox/netbox/configuration_testing.py) — Test configuration used by the runner.

+ 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.2
     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.2
     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.2
     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.

+ 156 - 63
.github/workflows/ci.yml

@@ -1,23 +1,26 @@
+---
 name: CI
 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:
@@ -25,14 +28,68 @@ 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: >-
+      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:
+          - coverage: false
+          # Run coverage only once, using the Python 3.14 job.
+          - python-version: '3.14'
+            coverage: true
     services:
     services:
       redis:
       redis:
         image: redis
         image: redis
@@ -52,62 +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
-      run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
-
-    - name: Show coverage report
-      run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*'
+      - 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

+ 52 - 19
.gitignore

@@ -1,33 +1,66 @@
-*.pyc
-*.swp
-npm-debug.log*
+# Python bytecode, cache directories, and test coverage output
+__pycache__/
+*.py[cod]
+.coverage
+
+# Python virtual environment created by the installation/upgrade workflow
+/venv/
+
+# Frontend dependencies and Yarn logs generated during asset development/builds
+/netbox/project-static/node_modules/
 yarn-debug.log*
 yarn-debug.log*
 yarn-error.log*
 yarn-error.log*
-/netbox/project-static/node_modules
-/netbox/project-static/docs/*
-!/netbox/project-static/docs/.info
+
+# AI tooling
+.claude/settings.local.json
+
+# Documentation generated by the upgrade/build workflow
+/netbox/project-static/docs/
+
+# Static files collected by Django
+/netbox/static/
+
+# Local NetBox configuration files created or copied during installation
 /netbox/netbox/configuration.py
 /netbox/netbox/configuration.py
 /netbox/netbox/ldap_config.py
 /netbox/netbox/ldap_config.py
-/netbox/local/*
+/local_requirements.txt
+
+# Local settings overrides loaded by settings.py if present
+/netbox/netbox/local_settings.py
+
+# Deployment-local files under the optional local directory
+/netbox/local/
+
+# User-uploaded media files; MEDIA_ROOT defaults to netbox/media/.
+# Keep the placeholder so the directory exists in a fresh checkout.
 /netbox/media/*
 /netbox/media/*
 !/netbox/media/.gitkeep
 !/netbox/media/.gitkeep
+
+# Legacy custom reports; REPORTS_ROOT defaults to netbox/reports/.
+# Keep the package marker while ignoring deployment-specific reports.
 /netbox/reports/*
 /netbox/reports/*
 !/netbox/reports/__init__.py
 !/netbox/reports/__init__.py
+
+# Custom scripts; SCRIPTS_ROOT defaults to netbox/scripts/.
+# Keep the package marker while ignoring deployment-specific scripts.
 /netbox/scripts/*
 /netbox/scripts/*
 !/netbox/scripts/__init__.py
 !/netbox/scripts/__init__.py
-/netbox/static
-/venv/
+
+# Deployment-local WSGI configuration copied from contrib/ and edited in place
+/gunicorn.py
+/uwsgi.ini
+
+# Ignore local helper scripts in the repository root, but keep the tracked upgrade script
 /*.sh
 /*.sh
-local_requirements.txt
-local_settings.py
 !upgrade.sh
 !upgrade.sh
-fabfile.py
-gunicorn.py
-uwsgi.ini
-netbox.log
-netbox.pid
+
+# Git patch/diff files commonly generated locally for review or handoff
+/*.patch
+/*.diff
+
+# Common local editor, OS, and runtime-manager metadata
+*.swp
 .DS_Store
 .DS_Store
-.idea
-.coverage
-.vscode
+.idea/
+.vscode/
 .python-version
 .python-version

+ 319 - 0
AGENTS.md

@@ -0,0 +1,319 @@
+# NetBox
+
+## Repository Overview
+
+NetBox is an extensible open-source network source-of-truth application powering network automation. It manages network infrastructure data including data center infrastructure (DCIM), IP address management (IPAM), circuits, virtualization, wireless, VPNs, and more. It supports a plugin ecosystem and exposes both a REST API and GraphQL API.
+
+NetBox is the core product maintained by NetBox Labs. The current version is 4.6 (Python 3.12+, Django 6.x).
+
+## Tech Stack
+
+- Python 3.12+ / Django 6.x / Django REST Framework 3.x
+- PostgreSQL (required), Redis (required for caching/queuing)
+- GraphQL via Strawberry, background jobs via django-rq
+- django-tables2 for list views, django-filter for filtering
+- drf-spectacular for OpenAPI/Swagger schema generation
+- Docs: MkDocs with mkdocs-material theme (in `docs/`)
+- Ruff for lint (config in `pyproject.toml`)
+
+## Repository Map
+
+```text
+.
+├── netbox/                    — Django project root (run manage.py from here)
+│   ├── manage.py
+│   ├── netbox/                — Core settings, URLs, WSGI, plugin infrastructure
+│   │   ├── settings.py        — Main Django settings
+│   │   ├── configuration.py   — Instance configuration (gitignored)
+│   │   ├── configuration_example.py — Configuration template
+│   │   ├── configuration_testing.py — Test configuration
+│   │   ├── urls.py            — Root URL routing
+│   │   ├── wsgi.py            — WSGI entrypoint
+│   │   ├── api/               — Core REST API infrastructure
+│   │   ├── graphql/           — Core GraphQL schema
+│   │   ├── models/            — Core model infrastructure (features, mixins)
+│   │   ├── navigation/        — Navigation menu system
+│   │   ├── plugins/           — Plugin system infrastructure
+│   │   ├── registry.py        — Object registry
+│   │   ├── search/            — Full-text search implementation
+│   │   ├── ui/                — UI utilities
+│   │   └── tests/             — Core framework tests
+│   ├── account/               — User account management
+│   ├── circuits/              — Circuit and provider management
+│   ├── core/                  — Core data management (data sources, jobs)
+│   ├── dcim/                  — Data center infrastructure (devices, racks, cables, etc.)
+│   ├── extras/                — Cross-cutting features (custom fields, tags, webhooks, scripts)
+│   ├── ipam/                  — IP address management (prefixes, addresses, VLANs, etc.)
+│   ├── tenancy/               — Tenancy and organization
+│   ├── users/                 — User management and tokens
+│   ├── utilities/             — Shared utilities (no models)
+│   ├── virtualization/        — Virtual machines and clusters
+│   ├── vpn/                   — VPN tunnels and configurations
+│   ├── wireless/              — Wireless LANs and links
+│   ├── templates/             — Django templates (per-app subdirectories)
+│   ├── static/                — Compiled static assets
+│   ├── project-static/        — Source static assets
+│   ├── media/                 — User-uploaded media
+│   └── translations/          — i18n translation files
+├── docs/                      — MkDocs documentation source
+│   ├── administration/
+│   ├── configuration/
+│   ├── customization/
+│   ├── development/           — Contributing guide, code style
+│   ├── features/
+│   ├── getting-started/
+│   ├── installation/
+│   ├── integrations/
+│   ├── models/                — Per-model documentation (by app)
+│   ├── plugins/
+│   ├── reference/
+│   └── release-notes/
+├── scripts/                   — Database management and verification scripts
+├── contrib/                   — Example configs (systemd, nginx, generated schemas)
+├── pyproject.toml             — Project metadata, ruff config
+├── requirements.txt           — Python dependencies
+└── mkdocs.yml                 — Docs site configuration
+```
+
+## Architecture
+
+### App Structure
+
+Each Django app (account, circuits, core, dcim, extras, ipam, tenancy, users, virtualization, vpn, wireless) follows a standard layout:
+
+```text
+<app>/
+├── __init__.py
+├── models/          — Database models (or models.py for smaller apps)
+├── migrations/      — Database migrations
+├── api/
+│   ├── serializers.py
+│   ├── views.py     — DRF viewsets
+│   └── urls.py      — NetBoxRouter registrations
+├── forms/           — Django forms (model forms, filter forms, bulk edit, etc.)
+├── tables/          — django-tables2 table definitions
+├── graphql/
+│   └── types.py     — Strawberry GraphQL types
+├── filtersets.py    — django-filter FilterSets
+├── choices.py       — ChoiceSet subclasses
+├── views.py         — UI views (registered with register_model_view())
+├── urls.py          — URL routing
+├── search.py        — SearchIndex registrations
+├── signals.py       — Django signal definitions (where applicable)
+└── tests/
+    ├── test_api.py
+    ├── test_filtersets.py
+    ├── test_models.py
+    ├── test_views.py
+    └── test_forms.py
+```
+
+### Views
+
+Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets — prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
+
+### REST API
+
+DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. `NetBoxModelSerializer` provides standard fields including `url`, `display`, `tags`, and `custom_fields`. drf-spectacular generates the OpenAPI schema automatically. REST API views typically don't need to add `select_related()` or `prefetch_related()` — prefetching is handled dynamically by the serializer.
+
+### GraphQL
+
+Strawberry types live in `<app>/graphql/types.py`. The core GraphQL schema is assembled in `netbox/netbox/graphql/`. Use Strawberry's `@strawberry.type` and `auto` field resolution, following the patterns in existing apps.
+
+### Background Jobs
+
+django-rq drives background task processing. Job classes live in `core/jobs.py` and app-specific `jobs.py` files. Use `JobRunner` subclasses (from `netbox.jobs`) for all background work. The `core` app exposes job status in the UI.
+
+### Plugin System
+
+Plugin infrastructure lives in `netbox/netbox/plugins/`. Plugins are Django apps registered in `PLUGINS` (configuration.py). The plugin API exposes stable extension points: custom models, views, navigation, template extensions, search indexes, object actions, and event rules. Internal NetBox APIs are subject to change without notice.
+
+### Filtering
+
+FilterSets live in `<app>/filtersets.py`, using `NetBoxModelFilterSet` as the base. Used for both UI filtering and API `?field=` params. FK filters must declare an explicit `<field>_id = ModelMultipleChoiceFilter(field_name='<field>', ...)` — don't rely on `Meta.fields` to auto-generate `_id` variants.
+
+### Extras App
+
+`extras` is a catch-all for cross-cutting features: custom fields, custom links, tags, webhooks/event rules, export templates, config contexts, saved filters, bookmarks, notifications, scripts, and reports. New cross-cutting features belong here. Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
+
+## Commands
+
+All commands run from the `netbox/` subdirectory with the venv active. There is no Makefile or Justfile; use raw commands.
+
+| Command | What it does |
+|---|---|
+| `python manage.py runserver` | Start development server |
+| `python manage.py test` | Run full test suite (set `NETBOX_CONFIGURATION` first — see Testing) |
+| `python manage.py test --keepdb --parallel 4` | Faster test run (no DB rebuild, parallel) |
+| `python manage.py test dcim.tests.test_api` | Run a single test module |
+| `python manage.py makemigrations` | Generate migrations after model changes |
+| `python manage.py migrate` | Apply migrations |
+| `python manage.py nbshell` | NetBox-enhanced interactive shell |
+| `python manage.py collectstatic` | Collect static assets |
+| `ruff check` | Lint (run from repo root) |
+| `mkdocs serve` | Preview documentation |
+| `mkdocs build` | Build static docs site |
+
+## Development Setup
+
+```bash
+python -m venv ~/.venv/netbox
+source ~/.venv/netbox/bin/activate
+pip install -r requirements.txt
+
+# Copy and configure
+cp netbox/netbox/configuration.example.py netbox/netbox/configuration.py
+# Edit configuration.py: set DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS
+
+cd netbox/
+python manage.py migrate
+python manage.py runserver
+```
+
+Requires PostgreSQL and Redis on localhost at their default ports.
+
+## Testing
+
+Tests use `django.test.TestCase` (not pytest). Test modules mirror the app structure in `<app>/tests/`. Always set the `NETBOX_CONFIGURATION` environment variable before running tests:
+
+```bash
+export NETBOX_CONFIGURATION=netbox.configuration_testing
+python manage.py test
+
+# Faster runs
+python manage.py test --keepdb --parallel 4
+
+# Single module
+python manage.py test dcim.tests.test_api
+```
+
+**Standard test modules per app:**
+
+| Module | Coverage area |
+|---|---|
+| `test_api.py` | REST API endpoints (CRUD, filtering, bulk operations) |
+| `test_filtersets.py` | FilterSet fields and query behavior |
+| `test_models.py` | Model methods, validation, constraints |
+| `test_views.py` | UI views (list, create, edit, delete, bulk actions) |
+| `test_forms.py` | Form validation |
+| `test_tables.py` | Table column rendering |
+
+Additional specialized test modules exist in some apps (e.g., `test_cablepaths.py` in dcim, `test_lookups.py` in ipam).
+
+## CI/CD
+
+GitHub Actions workflows in `.github/workflows/`:
+
+- **`ci.yml`** — Main CI pipeline: runs on every PR. Executes linting (ruff) and the full test suite across the supported Python version matrix.
+- **`codeql.yml`** — CodeQL security scanning.
+- **`claude.yml`** — Claude Code automation hook; triggers on issue/PR comments mentioning `@claude`.
+- **`claude-issue-triage.yml`** — Automated issue triage via Claude AI.
+- **`close-stale-issues.yml`** / **`close-incomplete-issues.yml`** — Issue hygiene automation.
+- **`lock-threads.yml`** — Locks closed issue/PR threads after a period.
+- **`update-translation-strings.yml`** — Extracts and updates i18n translation strings.
+
+## Common Tasks
+
+### Add a new model
+
+1. Add the model to the appropriate app's `models/` directory (or create a new module imported from `models/__init__.py`). Inherit from `NetBoxModel` for full feature support (custom fields, tags, etc.).
+2. Prompt the user to run `python manage.py makemigrations` — never write migrations manually.
+3. Wire up the full surface area: filterset (`filtersets.py`), forms (`forms/`), table (`tables/`), serializer (`api/serializers.py`), viewset (`api/views.py`), URL routes (`api/urls.py`, `urls.py`), UI views (`views.py`), navigation, and a template under `templates/<app>/`.
+4. Register a `SearchIndex` in `search.py` if the model should appear in global search.
+5. Add tests covering model logic, API, filtersets, forms, and views.
+
+### Add a REST API endpoint
+
+1. Add the serializer to `api/serializers.py` using `NetBoxModelSerializer` for `NetBoxModel`-based models. Include a `url` field.
+2. Add the viewset to `api/views.py`. For custom actions use `@action(detail=True, methods=['post'])`.
+3. Register the route in `api/urls.py` via `NetBoxRouter`.
+4. Ensure a corresponding `FilterSet` exists in `filtersets.py`; add explicit `<field>_id = ModelMultipleChoiceFilter(field_name='<field>', ...)` for FK filters.
+5. Add an integration test in `tests/test_api.py`.
+
+### Add a GraphQL type
+
+1. Add a Strawberry type to `<app>/graphql/types.py`, inheriting from the appropriate base (see existing types for examples).
+2. Register any new query fields in the app's GraphQL module and ensure it is included in the root schema.
+3. Follow the patterns in existing apps — use `auto` fields and lazy-resolve relations.
+
+### Add a filterset field
+
+1. Add the field to `<app>/filtersets.py`. Use `NetBoxModelFilterSet` as the base.
+2. For FK relations, add both `<field>` (name/slug lookup) and `<field>_id` (ID lookup) as explicit `ModelMultipleChoiceFilter` entries.
+3. Update the filter form in `forms/filtersets.py` to expose the field in the UI.
+4. Add a test in `tests/test_filtersets.py`.
+
+### Cut a release
+
+1. Bump `version` in `pyproject.toml`.
+2. Update `docs/release-notes/`.
+3. Tag and publish a GitHub release.
+
+## Conventions and Patterns
+
+- **Apps**: Each app owns its models, views, serializers, filtersets, forms, and tests. Don't reach across app boundaries except via FK relations and public APIs.
+- **Views**: Use `register_model_view()`. List views don't need manual `select_related()`/`prefetch_related()` — the table handles it.
+- **REST API**: Serializers don't need manual `select_related()`/`prefetch_related()` — handled dynamically.
+- **New models**: Inherit from `NetBoxModel`; include `created` and `last_updated` fields.
+- **Every UI model**: Needs model, serializer, filterset, form, table, views, URL route, and tests.
+- **API serializers**: Must include a `url` field (absolute URL of the object).
+- **Generic relations**: Use `FeatureQuery` for config contexts, custom fields, tags, etc.
+- **FK filters**: Always add explicit `<field>_id` variants in FilterSets; don't rely on `Meta.fields`.
+- **No new dependencies** without strong justification.
+- **No manual migrations**: Prompt the user to run `manage.py makemigrations`.
+- **No `ruff format`** on existing files — tends to introduce unnecessary style changes.
+- **Linting**: Ruff config in `pyproject.toml`. Line length 120, single quotes. Enabled rules: E/W/F/I/RET/UP/RUF022. Ignored: F403, F405, RET504, UP032.
+- **Extras**: Cross-cutting features (custom fields, tags, webhooks, scripts) belong in the `extras` app.
+- **Plugin API**: Only documented public APIs are stable. Internal code may change without notice.
+
+## Branch & PR Conventions
+
+- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
+- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
+- Every PR must reference an approved GitHub issue.
+- 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
+
+- **Wrong directory for `manage.py`** — `manage.py` lives in `netbox/`, not the repo root. Always `cd netbox/` first or use the full path.
+- **Wrong configuration loaded** — Set `NETBOX_CONFIGURATION=netbox.configuration_testing` for tests.
+- **`configuration.py` not found** — Copy `configuration.example.py` to `configuration.py` and fill in DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS. This file is gitignored and must never be committed.
+- **Migration errors** — Never write migrations manually. Run `python manage.py makemigrations` and let Django generate them.
+- **Plugin issues** — Only documented public APIs are stable. Internal NetBox code may change without notice.
+
+## Gotchas
+
+- `configuration.py` is gitignored — never commit it.
+- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
+- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
+- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
+- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
+- See `docs/development/` for the full contributing guide and code style details.
+
+## References
+
+- Documentation: [`docs/`](./docs/)
+- Contributing guide: [`docs/development/`](./docs/development/)
+- Release notes: [`docs/release-notes/`](./docs/release-notes/)
+- Plugin development: [`docs/plugins/`](./docs/plugins/)
+- NetBox Labs: <https://netboxlabs.com>

+ 1 - 87
CLAUDE.md

@@ -1,87 +1 @@
-# NetBox
-
-Network source-of-truth and infrastructure resource modeling (IRM) tool combining DCIM and IPAM. Built on Django + PostgreSQL + Redis.
-
-## Tech Stack
-- Python 3.12+ / Django / Django REST Framework
-- PostgreSQL (required), Redis (required for caching/queuing)
-- GraphQL via Strawberry, background jobs via RQ
-- Docs: MkDocs (in `docs/`)
-
-## Repository Layout
-- `netbox/` — Django project root; run all `manage.py` commands from here
-- `netbox/netbox/` — Core settings, URLs, WSGI entrypoint
-- `netbox/<app>/` — Django apps: `circuits`, `core`, `dcim`, `ipam`, `extras`, `tenancy`, `virtualization`, `wireless`, `users`, `vpn`
-- `docs/` — MkDocs documentation source
-- `contrib/` — Example configs (systemd, nginx, etc.) and other resources
-
-## Development Setup
-```bash
-python -m venv ~/.venv/netbox
-source ~/.venv/netbox/bin/activate
-pip install -r requirements.txt
-
-# Copy and configure
-cp netbox/netbox/configuration.example.py netbox/netbox/configuration.py
-# Edit configuration.py: set DATABASE, REDIS, SECRET_KEY, ALLOWED_HOSTS
-
-cd netbox/
-python manage.py migrate
-python manage.py runserver
-```
-
-## Key Commands
-All commands run from the `netbox/` subdirectory with venv active.
-
-```bash
-# Development server
-python manage.py runserver
-
-# Run full test suite
-export NETBOX_CONFIGURATION=netbox.configuration_testing
-python manage.py test
-
-# Faster test runs (no DB rebuild, parallel)
-python manage.py test --keepdb --parallel 4
-
-# Migrations
-python manage.py makemigrations
-python manage.py migrate
-
-# Shell
-python manage.py nbshell   # NetBox-enhanced shell
-```
-
-## Architecture Conventions
-- **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
-- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
-- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
-- **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
-- **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
-- **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
-- **Templates**: Django templates in `netbox/templates/<app>/`.
-- **Tests**: Mirror the app structure in `<app>/tests/`. Use `netbox.configuration_testing` for test config.
-
-## Coding Standards
-- Follow existing Django conventions; don't reinvent patterns already present in the codebase.
-- New models must include `created`, `last_updated` fields (inherit from `NetBoxModel` where appropriate).
-- Every model exposed in the UI needs: model, serializer, filterset, form, table, views, URL route, and tests.
-- API serializers must include a `url` field (absolute URL of the object).
-- Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
-- Avoid adding new dependencies without strong justification.
-- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
-- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
-
-## Branch & PR Conventions
-- Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
-- Use the `main` branch for patch releases; `feature` tracks work for the upcoming minor/major release.
-- Every PR must reference an approved GitHub issue.
-- PRs must include tests for new functionality.
-
-## Gotchas
-- `configuration.py` is gitignored — never commit it.
-- `manage.py` lives in `netbox/`, NOT the repo root. Running from the wrong directory is a common mistake.
-- `NETBOX_CONFIGURATION` env var controls which settings module loads; set to `netbox.configuration_testing` for tests.
-- The `extras` app is a catch-all for cross-cutting features (custom fields, tags, webhooks, scripts).
-- Plugins API: only documented public APIs are stable. Internal NetBox code is subject to change without notice.
-- See `docs/development/` for the full contributing guide and code style details.
+@./AGENTS.md

+ 4 - 4
CONTRIBUTING.md

@@ -35,7 +35,10 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
 * Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
 * Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
 
 
 > [!CAUTION]
 > [!CAUTION]
-> We do not currently accept issues submitted via GitHub's API: All issues must be submitted using one of the [provided templates](https://github.com/netbox-community/netbox/issues/new/choose). The templates not only help ensure high-quality submissions, but they also automatically assign issue types and labels for categorization. This does not happen when issues are submitted via the API.
+> We do not currently accept issues submitted via GitHub's API: All issues must be submitted using one of the [provided templates](https://github.com/netbox-community/netbox/issues/new/choose). In addition to ensuring high-quality submissions, these templates automatically assign issue types and labels for categorization to help expedite triage. This does not happen when issues are submitted via the API.
+
+> [!IMPORTANT]
+> Every issue submitted to this repository is afforded consideration by a human reviewer. To mitigate abuse, we ask that users refrain from submitting AI-generated issues. Please note that issues which appear to be completely authored by an AI may be rejected without further discussion.
 
 
 ## :bug: Reporting Bugs
 ## :bug: Reporting Bugs
 
 
@@ -99,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.

Diff do ficheiro suprimidas por serem muito extensas
+ 909 - 12
contrib/openapi.json


+ 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.

+ 0 - 2
docs/configuration/error-reporting.md

@@ -41,5 +41,3 @@ SENTRY_TAGS = {
 !!! warning "Reserved tag prefixes"
 !!! warning "Reserved tag prefixes"
     Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
     Avoid using any tag names which begin with `netbox.`, as this prefix is reserved by the NetBox application.
 
 
----
-

+ 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.

+ 15 - 1
docs/configuration/miscellaneous.md

@@ -190,7 +190,21 @@ Setting this to `True` will display a "maintenance mode" banner at the top of ev
 
 
 Default: `https://maps.google.com/?q=` (Google Maps)
 Default: `https://maps.google.com/?q=` (Google Maps)
 
 
-This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
+This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. Set this to `None` to disable the "map it" button within the UI.
+
+**For street addresses**, the URL must accept a free-form address string appended directly to it.
+
+**For GPS coordinates**, two formats are supported:
+
+* **Simple prefix** (default behavior): The latitude and longitude are appended as a comma-separated pair. For example, `https://maps.google.com/?q=` produces `https://maps.google.com/?q=48.858,2.294`.
+* **Coordinate placeholders**: Include `{lat}` and/or `{lon}` anywhere in the URL. Only these two literal placeholders are supported. For example:
+
+```
+MAPS_URL = "https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}"
+```
+
+!!! note
+    When `MAPS_URL` contains `{lat}` or `{lon}` placeholders, the "map it" button will only appear on pages with GPS coordinates — address-based map links will be suppressed, since the coordinate-format URL cannot be used with a plain address string.
 
 
 ---
 ---
 
 

+ 3 - 3
docs/configuration/security.md

@@ -153,7 +153,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
 
 
 Default: `False`
 Default: `False`
 
 
-If `True`, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
+If `True`, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days, and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
 
 
 Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
 Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
 
 
@@ -175,9 +175,9 @@ When enabled, only authenticated users are permitted to access any part of NetBo
 
 
 ## LOGIN_TIMEOUT
 ## LOGIN_TIMEOUT
 
 
-Default: `1209600` seconds (14 days)
+Default: `None`
 
 
-The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
+The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. If set to `None` (the default), Django's [`SESSION_COOKIE_AGE`](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-age) is used, which defaults to two weeks (1,209,600 seconds).
 
 
 ---
 ---
 
 

+ 51 - 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`
@@ -125,6 +145,24 @@ Set this configuration parameter to `True` for NetBox deployments which do not h
 
 
 ---
 ---
 
 
+## JINJA_ENVIRONMENT_PARAMS
+
+Default: `[]`
+
+A list of system environment variable names which may be referenced from within Jinja2 templates via the built-in [`env`](#jinja2_filters) filter. Patterns may include wildcards (matched using Python's `fnmatch` syntax). Any variable whose name does not match an entry in this list cannot be referenced from a template. For example:
+
+```python
+JINJA_ENVIRONMENT_PARAMS = [
+    'WEBHOOK_TOKEN_*',
+    'DEFAULT_SECRET_ID',
+]
+```
+
+!!! info "Parameter names are case-sensitive"
+    For example, `FOO_*` will match `FOO_BAR` but `foo_*` will not.
+
+---
+
 ## JINJA2_FILTERS
 ## JINJA2_FILTERS
 
 
 Default: `{}`
 Default: `{}`
@@ -140,6 +178,18 @@ JINJA2_FILTERS = {
 }
 }
 ```
 ```
 
 
+NetBox also registers the following filters by default. Any entry defined in `JINJA2_FILTERS` with the same name will override the default.
+
+| Filter | Description |
+|---|---|
+| `env` | Returns the value of the system environment variable with the given name, provided its name matches an entry in [`JINJA_ENVIRONMENT_PARAMS`](#jinja_environment_params). Returns `None` if the variable is not defined or its name is not whitelisted. |
+
+For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 template may reference an environment variable as:
+
+```
+Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }}
+```
+
 ---
 ---
 
 
 ## LOGGING
 ## LOGGING

+ 33 - 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.
@@ -325,6 +357,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
 * `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
 * `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
 * `null_option` - A label representing a "null" or empty choice (optional)
 * `null_option` - A label representing a "null" or empty choice (optional)
 * `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
 * `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
+* `quick_add` - A boolean that, when True, includes a quick add widget, to create a new related object for assignment. (optional; False by default)
 
 
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
 To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
 
 

+ 12 - 0
docs/development/getting-started.md

@@ -186,6 +186,18 @@ This is handy for instances where just a few tests are failing and you want to r
 !!! info
 !!! info
     NetBox uses [django-rich](https://github.com/adamchainz/django-rich) to enhance Django's default `test` management command.
     NetBox uses [django-rich](https://github.com/adamchainz/django-rich) to enhance Django's default `test` management command.
 
 
+### SQL Query Count Baselines
+
+The shared list-test mixins assert the number of SQL queries each list endpoint performs against a baseline checked in alongside the tests. This guards against the accidental introduction of new queries (e.g. N+1 patterns) when a queryset, serializer, or table changes. Baselines are stored per app at `netbox/<app>/tests/query_counts.json`, keyed by `<model_name>:<test_name>`.
+
+If a list test fails with a message like `Query count for dcim/site:list_objects changed: expected 16, got 18`, first investigate whether the change is expected. If the new count is correct (e.g. you intentionally added a `prefetch_related`, or removed one), regenerate the baseline:
+
+```no-highlight
+UPDATE_QUERY_COUNTS=1 python manage.py test --keepdb
+```
+
+`UPDATE_QUERY_COUNTS` mode requires serial execution; do not combine it with `--parallel`. You can target a single test, app, or the full suite — only the keys exercised by the run are updated. Review the resulting diff in the JSON files as part of the PR; a reviewer should be able to see and reason about every query-count change.
+
 ## Submitting Pull Requests
 ## Submitting Pull Requests
 
 
 Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. Be sure to prefix your commit message with the word "Fixes" or "Closes" and the relevant issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.
 Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. Be sure to prefix your commit message with the word "Fixes" or "Closes" and the relevant issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.

+ 6 - 0
docs/features/context-data.md

@@ -84,3 +84,9 @@ Devices and virtual machines may also have a local context data defined. This lo
 
 
 !!! warning
 !!! warning
     If you find that you're routinely defining local context data for many individual devices or virtual machines, [custom fields](./customization.md#custom-fields) may offer a more effective solution.
     If you find that you're routinely defining local context data for many individual devices or virtual machines, [custom fields](./customization.md#custom-fields) may offer a more effective solution.
+
+## Profiles & Schema Validation
+
+A [config context profile](../models/extras/configcontextprofile.md) provides an organizational grouping for related config contexts and may optionally enforce a [JSON schema](https://json-schema.org/) describing the shape of their data. When a profile is assigned to a config context, NetBox validates the context's data against the profile's schema on save and rejects any context that fails validation. This makes it possible to constrain which keys may appear in a context, require certain keys to be present, or limit values to a defined enumeration — guarding against typos and drift as contexts proliferate.
+
+A profile's schema may be authored directly in NetBox or populated from an external [data source](../models/core/datasource.md), enabling teams to maintain schemas alongside the code or configurations that consume them.

+ 19 - 2
docs/features/devices-cabling.md

@@ -6,18 +6,23 @@ NetBox uses device types to represent unique real-world device models. This allo
 
 
 ```mermaid
 ```mermaid
 flowchart TD
 flowchart TD
-    Manufacturer -.-> Platform & DeviceType & ModuleType
+    Manufacturer -.-> Platform
     Manufacturer --> DeviceType & ModuleType
     Manufacturer --> DeviceType & ModuleType
+    ModuleTypeProfile -.-> ModuleType
     DeviceRole & Platform & DeviceType --> Device
     DeviceRole & Platform & DeviceType --> Device
     Device & ModuleType ---> Module
     Device & ModuleType ---> Module
     Device & Module --> Interface & ConsolePort & PowerPort & ...
     Device & Module --> Interface & ConsolePort & PowerPort & ...
+    Interface --> MACAddress
 
 
 click Device "../../models/dcim/device/"
 click Device "../../models/dcim/device/"
 click DeviceRole "../../models/dcim/devicerole/"
 click DeviceRole "../../models/dcim/devicerole/"
 click DeviceType "../../models/dcim/devicetype/"
 click DeviceType "../../models/dcim/devicetype/"
+click Interface "../../models/dcim/interface/"
+click MACAddress "../../models/dcim/macaddress/"
 click Manufacturer "../../models/dcim/manufacturer/"
 click Manufacturer "../../models/dcim/manufacturer/"
 click Module "../../models/dcim/module/"
 click Module "../../models/dcim/module/"
 click ModuleType "../../models/dcim/moduletype/"
 click ModuleType "../../models/dcim/moduletype/"
+click ModuleTypeProfile "../../models/dcim/moduletypeprofile/"
 click Platform "../../models/dcim/platform/"
 click Platform "../../models/dcim/platform/"
 ```
 ```
 
 
@@ -69,15 +74,23 @@ Sometimes it is necessary to model a set of physical devices as sharing a single
 
 
 A virtual device context (VDC) is a logical partition within a device. Each VDC operates autonomously but shares a common pool of resources. Each interface can be assigned to one or more VDCs on its device.
 A virtual device context (VDC) is a logical partition within a device. Each VDC operates autonomously but shares a common pool of resources. Each interface can be assigned to one or more VDCs on its device.
 
 
-## Module Types & Modules
+## Module Types, Profiles & Modules
 
 
 Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays.
 Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays.
 
 
+### Module Type Profiles
+
+A [module type profile](../models/dcim/moduletypeprofile.md) classifies module types (e.g. `Power Supply`, `Disk`) and may optionally define a [JSON schema](https://json-schema.org/) describing custom attributes that module types of that profile may carry. This is useful for tracking domain-specific specifications such as a power supply's input voltage, a CPU's clock speed, or a disk's capacity, without needing to add a custom field to every module type in NetBox.
+
 !!! tip "Device Bays vs. Module Bays"
 !!! tip "Device Bays vs. Module Bays"
     What's the difference between device bays and module bays? Device bays are appropriate when the installed hardware has its own management plane, isolated from the parent device. A common example is a blade server chassis in which the blades share power but operate independently. In contrast, a module bay holds a module which does _not_ operate independently of its parent device, as with the chassis switch line card example mentioned above.
     What's the difference between device bays and module bays? Device bays are appropriate when the installed hardware has its own management plane, isolated from the parent device. A common example is a blade server chassis in which the blades share power but operate independently. In contrast, a module bay holds a module which does _not_ operate independently of its parent device, as with the chassis switch line card example mentioned above.
 
 
 One especially nice feature of modules is that templated components can be automatically renamed according to the module bay into which the parent module is installed. For example, if we create a module type with interfaces named `Gi{module}/0/1-48` and install a module of this type into module bay 7 of a device, NetBox will create interfaces named `Gi7/0/1-48`.
 One especially nice feature of modules is that templated components can be automatically renamed according to the module bay into which the parent module is installed. For example, if we create a module type with interfaces named `Gi{module}/0/1-48` and install a module of this type into module bay 7 of a device, NetBox will create interfaces named `Gi7/0/1-48`.
 
 
+## MAC Addresses
+
+[MAC addresses](../models/dcim/macaddress.md) are modeled as first-class objects in NetBox so that an interface may have multiple MAC addresses assigned to it, with one optionally designated as the interface's primary MAC. This accommodates virtual interfaces and modular hardware where the link-layer address is not necessarily fixed at the factory. MAC addresses can be assigned to both [device interfaces](../models/dcim/interface.md) and [virtual machine interfaces](../models/virtualization/vminterface.md).
+
 ## Cables
 ## Cables
 
 
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)
 NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.)
@@ -89,3 +102,7 @@ flowchart LR
     Interface --> Cable
     Interface --> Cable
     Cable --> fp1[Front Port] & fp2[Front Port]
     Cable --> fp1[Front Port] & fp2[Front Port]
 ```
 ```
+
+### Cable Bundles
+
+Related cables can optionally be grouped into a [cable bundle](../models/dcim/cablebundle.md), representing a logical collection such as a conduit, trunk, or wiring harness. Bundle membership is purely organizational: it does not affect cable tracing or connectivity. Deleting a cable removes it from its bundle but does not delete the bundle itself, allowing bundles to outlive any specific member cable.

+ 7 - 1
docs/features/facilities.md

@@ -13,10 +13,12 @@ flowchart TD
     Rack --> Device
     Rack --> Device
     Site --> Rack
     Site --> Rack
     RackRole --> Rack
     RackRole --> Rack
+    RackGroup --> Rack
 
 
 click Device "../../models/dcim/device/"
 click Device "../../models/dcim/device/"
 click Location "../../models/dcim/location/"
 click Location "../../models/dcim/location/"
 click Rack "../../models/dcim/rack/"
 click Rack "../../models/dcim/rack/"
+click RackGroup "../../models/dcim/rackgroup/"
 click RackRole "../../models/dcim/rackrole/"
 click RackRole "../../models/dcim/rackrole/"
 click Region "../../models/dcim/region/"
 click Region "../../models/dcim/region/"
 click Site "../../models/dcim/site/"
 click Site "../../models/dcim/site/"
@@ -60,11 +62,15 @@ A location can be any logical subdivision within a building, such as a floor or
 
 
 A rack type represents a unique specification of a rack which exists in the real world. Each rack type can be setup with weight, height, and unit ordering. New racks of this type can then be created in NetBox, and any associated specifications will be automatically replicated from the device type.
 A rack type represents a unique specification of a rack which exists in the real world. Each rack type can be setup with weight, height, and unit ordering. New racks of this type can then be created in NetBox, and any associated specifications will be automatically replicated from the device type.
 
 
+## Rack Groups
+
+In addition to being assigned to a [location](#locations), racks may optionally be assigned to a [rack group](../models/dcim/rackgroup.md). Rack groups are flat (non-hierarchical) and exist alongside locations as a secondary axis of grouping — particularly handy for organizing racks by row, aisle, or pod within a single location, or for scoping [VLAN groups](../models/ipam/vlangroup.md) to a subset of racks.
+
 ## Racks
 ## Racks
 
 
 Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions.
 Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions.
 
 
-Each rack must be associated to a site, but the assignment to a location within that site is optional. Users can also create custom roles to which racks can be assigned. NetBox supports tracking rack space in half-unit increments, so it's possible to mount devices at e.g. position 2.5 within a rack.
+Each rack must be associated to a site, but the assignment to a location or rack group within that site is optional. Users can also create custom roles to which racks can be assigned. NetBox supports tracking rack space in half-unit increments, so it's possible to mount devices at e.g. position 2.5 within a rack.
 
 
 !!! tip "Devices"
 !!! tip "Devices"
     You'll notice in the diagram above that a device can be installed within a site, location, or rack. This approach affords plenty of flexibility as not all sites need to define child locations, and not all devices reside in racks.
     You'll notice in the diagram above that a device can be installed within a site, location, or rack. This approach affords plenty of flexibility as not all sites need to define child locations, and not all devices reside in racks.

+ 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 15 or later required"
 !!! warning "PostgreSQL 15 or later required"
     NetBox requires PostgreSQL 15 or later. Please note that MySQL and other relational databases are **not** supported.
     NetBox requires PostgreSQL 15 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

+ 28 - 1
docs/installation/index.md

@@ -33,7 +33,34 @@ The following sections detail how to set up a new instance of NetBox:
 
 
 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
 
 

+ 15 - 1
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.
@@ -27,6 +40,7 @@ NetBox requires the following dependencies:
 
 
 | 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) |

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

@@ -663,6 +663,51 @@ Note that there is no requirement for the attributes to be identical among objec
 !!! note
 !!! note
     The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated.
     The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated.
 
 
+### Concurrent Update Protection
+
+!!! info "This feature was introduced in NetBox v4.6."
+
+To guard against the lost-update problem when multiple clients modify the same object, NetBox returns a weak `ETag` response header on detail-view responses (`GET`, `POST`, `PATCH`, `PUT`) for individual objects. Clients may supply this value back on a subsequent `PATCH` or `PUT` request via the `If-Match` request header. If the object's current ETag does not match any of the values supplied, the server rejects the request with a `412 Precondition Failed` response and includes the current ETag in the response so the client can retry.
+
+```no-highlight
+# Capture the ETag returned with the object
+$ curl -s -i -H "Authorization: Bearer $TOKEN" http://netbox/api/dcim/sites/1/ | grep -i ^etag
+ETag: W/"2026-05-01T17:42:11.123456+00:00"
+
+# Submit an update with If-Match referencing that ETag
+$ curl -s -X PATCH \
+    -H "Authorization: Bearer $TOKEN" \
+    -H "Content-Type: application/json" \
+    -H 'If-Match: W/"2026-05-01T17:42:11.123456+00:00"' \
+    http://netbox/api/dcim/sites/1/ \
+    --data '{"status": "decommissioning"}'
+```
+
+A literal `If-Match: *` value matches any current ETag and may be used to assert simply that the object exists. Submitting `If-Match` is optional; requests without the header retain prior (last-write-wins) behavior.
+
+### Adding and Removing Tags
+
+!!! info "This feature was introduced in NetBox v4.6."
+
+In addition to replacing an object's tag set wholesale via the `tags` field, taggable models accept two write-only fields, `add_tags` and `remove_tags`, which apply only the specified additions or removals without disturbing existing tags. This is convenient when concurrent clients each manage a distinct subset of an object's tags.
+
+```no-highlight
+curl -s -X PATCH \
+-H "Authorization: Bearer $TOKEN" \
+-H "Content-Type: application/json" \
+http://netbox/api/dcim/sites/1/ \
+--data '{
+    "add_tags": [{"name": "production"}],
+    "remove_tags": [{"name": "staging"}]
+}'
+```
+
+Constraints:
+
+* `tags` may not be combined with `add_tags` or `remove_tags` in the same request.
+* `remove_tags` is only valid on updates; it cannot be used when creating a new object.
+* The same tag may not appear in both `add_tags` and `remove_tags`.
+
 ### Deleting an Object
 ### Deleting an Object
 
 
 To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body.
 To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body.
@@ -747,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.
 
 
@@ -871,3 +919,11 @@ GET /api/dcim/sites/?created_by_request=e39c84bc-f169-4d5f-bc1c-94487a1b18b5
 
 
 !!! note
 !!! note
     This header is included with _all_ NetBox responses, although it is most practical when working with an API.
     This header is included with _all_ NetBox responses, although it is most practical when working with an API.
+
+### `ETag`
+
+A weak entity tag (e.g. `W/"2026-05-01T17:42:11.123456+00:00"`) returned on detail-view responses for individual objects. The value is derived from the object's `last_updated` timestamp (or `created`, if the object has no `last_updated`). Clients may supply this value on a subsequent write request via the `If-Match` header to perform a conditional update. See [Concurrent Update Protection](#concurrent-update-protection) for details.
+
+### `If-Match`
+
+A request header which may be supplied on `PATCH` or `PUT` requests targeting a single object. If the object's current ETag does not match any value supplied, the request is rejected with a `412 Precondition Failed` response. A literal value of `*` matches any existing object. See [Concurrent Update Protection](#concurrent-update-protection) for details.

BIN
docs/media/installation/netbox_application_stack.png


BIN
docs/media/installation/upgrade_paths.png


+ 43 - 0
docs/models/core/objectchange.md

@@ -0,0 +1,43 @@
+# Object Changes
+
+An object change is a record of a single create, update, or delete operation against an object whose model supports [change logging](../../features/change-logging.md). Object changes form a complete audit trail: each one captures the user that initiated the change, the request that caused it, the action performed, and a JSON snapshot of the object before and after.
+
+For component objects (e.g. an interface on a device), an object change can also reference a related parent object so that the change appears in the parent's changelog as well as the component's own.
+
+## Fields
+
+### Time
+
+The date and time at which the change was recorded.
+
+### User & User Name
+
+The [user](../users/user.md) who initiated the change. The user's username is also stored as a static string (`user_name`) so that change records remain readable even after the user account is deleted.
+
+### Request ID
+
+A UUID identifying the request that produced the change. All changes resulting from a single request share the same request ID, which makes it easy to correlate related modifications. The same value is returned on REST API responses via the `X-Request-ID` header.
+
+### Action
+
+The type of operation performed: `create`, `update`, or `delete`.
+
+### Changed Object
+
+A generic foreign key (`changed_object_type` + `changed_object_id`) identifying the object that was modified.
+
+### Related Object
+
+An optional generic foreign key referencing a related object (e.g. the parent device for a changed interface). When set, the change is also surfaced in the related object's changelog.
+
+### Object Representation
+
+A static text representation of the changed object, captured at the time of the change. Preserved so that the change record is meaningful even after the underlying object is deleted.
+
+### Message
+
+An optional free-form message attached to the change. May be supplied via the UI (in eligible forms) or via the [REST API](../../integrations/rest-api.md#changelog-messages) using the `changelog_message` field.
+
+### Pre-Change Data & Post-Change Data
+
+JSON snapshots of the object's serialized state immediately before and immediately after the change. For `create` actions, only post-change data is recorded; for `delete` actions, only pre-change data. The diff displayed in the UI is computed from these snapshots.

+ 26 - 0
docs/models/core/objecttype.md

@@ -0,0 +1,26 @@
+# Object Types
+
+An object type identifies a NetBox model by its app label and model name (e.g. `dcim.device`). Object types are used wherever NetBox needs to refer to a model dynamically — most commonly in [custom fields](../extras/customfield.md), [object permissions](../users/objectpermission.md), [export templates](../extras/exporttemplate.md), [event rules](../extras/eventrule.md), and generic relations such as the assignment of an [IP address](../ipam/ipaddress.md) to either a device or VM interface.
+
+Object types extend Django's stock `ContentType` model with two additional attributes that NetBox uses to reason about model capabilities: `public` (whether the model is intended for direct reference) and `features` (the set of NetBox model features the model supports, such as change logging or custom fields).
+
+!!! note "For plugin authors"
+    NetBox code should generally use `ObjectType.objects.get_for_model()` rather than Django's `ContentType.objects.get_for_model()`, so that the resulting object exposes NetBox's `public` and `features` attributes. The two managers are otherwise interchangeable.
+
+## Fields
+
+### App Label
+
+The Django application label to which the model belongs (e.g. `dcim`, `ipam`, or a plugin's app label).
+
+### Model
+
+The lowercase model name (e.g. `device`, `prefix`).
+
+### Public
+
+Indicates whether the model is part of NetBox's public data model. Public models are those intended to be referenced from other objects (e.g. via custom fields or generic relations). Internal models — those backing implementation details — are non-public and are excluded from interfaces that expose model selection to end users.
+
+### Features
+
+The list of NetBox model features the underlying model supports (for example: `change_logging`, `custom_fields`, `tags`, `webhooks`). This list is consulted when filtering object types for a particular feature, e.g. when populating the model selector for an event rule.

+ 3 - 3
docs/models/dcim/cablebundle.md

@@ -1,8 +1,8 @@
 # Cable Bundles
 # Cable Bundles
 
 
-A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles can be used to organize cables that share a common purpose, route, or physical grouping (such as a conduit or harness).
+A cable bundle is a logical grouping of individual [cables](./cable.md). Bundles are useful for organizing cables that share a common purpose, route, or physical grouping such as a conduit, trunk, or wiring harness.
 
 
-Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself.
+Assigning cables to a bundle is optional and does not affect cable tracing or connectivity. Bundles persist independently of their member cables: deleting a cable clears its bundle assignment but does not delete the bundle itself, allowing the bundle to be reused for replacement cables.
 
 
 ## Fields
 ## Fields
 
 
@@ -12,4 +12,4 @@ A unique name for the cable bundle.
 
 
 ### Description
 ### Description
 
 
-A brief description of the bundle's purpose or contents.
+An optional short description of the bundle's purpose or contents.

+ 17 - 3
docs/models/dcim/macaddress.md

@@ -1,11 +1,25 @@
 # MAC Addresses
 # MAC Addresses
 
 
-A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface.
+A MAC address object in NetBox represents a single Ethernet link-layer address as reported by or assigned to a network interface. MAC addresses can be assigned to [device interfaces](./interface.md) and [virtual machine interfaces](../virtualization/vminterface.md), and any one of an interface's assigned MAC addresses may be designated as its **primary** MAC address.
 
 
-Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface.
+Most physical interfaces have only a single MAC address, hard-coded at the factory. However, some interfaces (particularly virtual interfaces and modular hardware) support multiple or reassignable MAC addresses. To accommodate this, NetBox models MAC addresses as first-class objects which may be created, modified, and reassigned independently of any specific interface.
 
 
 ## Fields
 ## Fields
 
 
 ### MAC Address
 ### MAC Address
 
 
-The 48-bit MAC address, in colon-hexadecimal notation (e.g. `aa:bb:cc:11:22:33`).
+The 48-bit MAC address, expressed in colon-hexadecimal notation (for example, `aa:bb:cc:11:22:33`).
+
+### Assigned Object
+
+A generic reference to the [device interface](./interface.md) or [virtual machine interface](../virtualization/vminterface.md) to which this MAC address is assigned. A MAC address may exist without being assigned to any interface.
+
+A MAC address that is currently designated as the primary MAC of its parent interface cannot be reassigned to (or unassigned from) another interface without first clearing the primary designation.
+
+### Description
+
+An optional human-readable description of the MAC address.
+
+### Comments
+
+Free-form Markdown-supported notes regarding the MAC address.

+ 2 - 1
docs/models/dcim/moduletype.md

@@ -11,8 +11,9 @@ Similar to [device types](./devicetype.md), each module type can have any of the
 * Power Outlets
 * Power Outlets
 * Front pass-through ports
 * Front pass-through ports
 * Rear pass-through ports
 * Rear pass-through ports
+* Module bays
 
 
-Note that device bays and module bays may _not_ be added to modules.
+Note that device bays may _not_ be added to modules.
 
 
 ## Automatic Component Renaming
 ## Automatic Component Renaming
 
 

+ 16 - 4
docs/models/dcim/moduletypeprofile.md

@@ -1,8 +1,8 @@
 # Module Type Profiles
 # Module Type Profiles
 
 
-Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
+Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes — for example, the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
 
 
-Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.
+Module type attributes are managed by configuring a [JSON schema](https://json-schema.org/) on the profile. The schema below introduces three module type attributes, two of which are designated as required:
 
 
 ```json
 ```json
 {
 {
@@ -29,10 +29,22 @@ Module type attributes are managed via the configuration of a [JSON schema](http
 }
 }
 ```
 ```
 
 
-The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed.
+Both the assignment of module types to a profile and the designation of a schema for a profile are optional: a profile can be used purely as a classification mechanism if the addition of custom attributes is not needed.
 
 
 ## Fields
 ## Fields
 
 
+### Name
+
+A unique name for the profile (for example, `Power Supply` or `Disk`).
+
+### Description
+
+An optional description of the profile.
+
 ### Schema
 ### Schema
 
 
-This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null).
+An optional [JSON schema](https://json-schema.org/) defining the attributes that may (or must) be set on each assigned module type. The schema must be valid JSON Schema, or else null.
+
+### Comments
+
+Free-form Markdown-supported notes about the profile.

+ 17 - 3
docs/models/extras/configcontextprofile.md

@@ -1,6 +1,6 @@
 # Config Context Profiles
 # Config Context Profiles
 
 
-Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The later is achieved by defining a [JSON schema](https://json-schema.org/) to which all config context with this profile assigned must comply.
+Profiles can be used to organize [configuration contexts](./configcontext.md) and to enforce a desired structure for their data. The latter is achieved by defining a [JSON schema](https://json-schema.org/) to which all config contexts assigned to the profile must comply. Any context whose data fails validation against the profile's schema cannot be saved.
 
 
 For example, the following schema defines two keys, `size` and `priority`, of which the former is required:
 For example, the following schema defines two keys, `size` and `priority`, of which the former is required:
 
 
@@ -22,12 +22,26 @@ For example, the following schema defines two keys, `size` and `priority`, of wh
 }
 }
 ```
 ```
 
 
+A profile's schema may also be populated from a [data source](../core/datasource.md), enabling the schema to be maintained externally (for example, in a git repository) and synchronized into NetBox.
+
 ## Fields
 ## Fields
 
 
 ### Name
 ### Name
 
 
-A unique human-friendly name.
+A unique, human-friendly name for the profile.
+
+### Description
+
+An optional description of the profile's purpose.
 
 
 ### Schema
 ### Schema
 
 
-The JSON schema to be enforced for all assigned config contexts (optional).
+An optional [JSON schema](https://json-schema.org/) document. When set, the schema is enforced for every config context assigned to this profile. Leaving the schema blank allows the profile to be used purely as an organizational grouping.
+
+### Data Source / Data File / Data Path
+
+Optional pointers to a remote [data source](../core/datasource.md) and file from which the schema is populated. When configured, the schema field is overwritten on synchronization.
+
+### Auto Sync Enabled
+
+When set, the profile's schema is automatically refreshed whenever the upstream data file is updated.

+ 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.
 
 

+ 10 - 0
docs/models/ipam/vlangroup.md

@@ -18,6 +18,16 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
 
 
 The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
 The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
 
 
+Internally, each range is stored in PostgreSQL as a canonical half-open `[start, end)` interval. The `start` value is the first VID included in the range; the `end` value sits one above the last VID included.
+
+The REST API and UI both present ranges using inclusive bounds, so most users never see the half-open form. Users working with the stored value directly, such as through psql, raw SQL, Django ORM access in Custom Scripts or plugins, or third-party tools, should expect this canonical representation.
+
+For the range covering VLANs 100 through 200:
+
+* UI input: `100-200`
+* REST API range item: `[100, 200]`
+* Database row: `[100, 201)`
+
 ### Total VLAN IDs
 ### Total VLAN IDs
 
 
 A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified.
 A read-only integer indicating the total count of VLAN IDs available within the group, calculated from the configured VLAN ID Ranges. For example, a group with ranges `100-199` and `300-399` would have a total of 200 VLAN IDs. This value is automatically computed and updated whenever the VLAN ID ranges are modified.

+ 19 - 0
docs/models/users/group.md

@@ -0,0 +1,19 @@
+# Groups
+
+A group is a collection of [users](./user.md) which share a common set of permissions. Assigning [object permissions](./objectpermission.md) to a group, rather than to individual users, simplifies the administration of permissions for related users (e.g. members of a particular team).
+
+A user inherits the union of all permissions assigned to each group of which they are a member, in addition to any permissions assigned directly to the user.
+
+## Fields
+
+### Name
+
+A unique name for the group.
+
+### Description
+
+A short description of the group's role or membership.
+
+### Object Permissions
+
+The set of [object permissions](./objectpermission.md) granted to all members of this group.

+ 50 - 0
docs/models/users/objectpermission.md

@@ -0,0 +1,50 @@
+# Object Permissions
+
+An object permission grants the ability to perform one or more actions (e.g. view, add, change, delete) against a defined set of object types, and may be restricted to a subset of objects matching a configured filter. Permissions are assigned to [users](./user.md) and/or [groups](./group.md); a user's effective permissions are the union of those assigned directly and those inherited via group membership.
+
+See the [permissions documentation](../../administration/permissions.md) for a detailed walkthrough of how permissions are evaluated.
+
+## Fields
+
+### Name
+
+A short, human-readable name for the permission.
+
+### Description
+
+An optional longer description of what the permission grants.
+
+### Enabled
+
+When unset, the permission is effectively disabled: it remains assigned to its users and groups, but is ignored during permission checks. This is useful for temporarily revoking access without altering assignments.
+
+### Object Types
+
+The list of NetBox model types to which this permission applies (e.g. `dcim.device`, `ipam.prefix`).
+
+### Actions
+
+The list of actions granted by the permission. The standard CRUD actions are `view`, `add`, `change`, and `delete`. Models may also register custom actions (e.g. `napalm` on `dcim.device`); custom actions appear here when supported by the selected object types.
+
+### Constraints
+
+An optional [Django ORM-style filter](https://docs.djangoproject.com/en/stable/topics/db/queries/#field-lookups) expressed as JSON. When set, the permission applies only to objects matching the filter. Multiple constraint sets may be supplied as a JSON list; an object matches if it satisfies any of the sets (logical OR).
+
+For example, to grant a permission only over devices in a specific site:
+
+```json
+{"site__slug": "ny-dc1"}
+```
+
+Or, to apply the permission to devices in either of two sites:
+
+```json
+[
+    {"site__slug": "ny-dc1"},
+    {"site__slug": "sj-dc2"}
+]
+```
+
+### Users & Groups
+
+The [users](./user.md) and [groups](./group.md) to which this permission is assigned.

+ 6 - 2
docs/models/users/ownergroup.md

@@ -1,9 +1,13 @@
 # Owner Groups
 # Owner Groups
 
 
-Groups are used to correlate and organize [owners](./owner.md). The assignment of an owner to a group has no bearing on the relationship of owned objects to their owners.
+Groups are used to correlate and organize [owners](./owner.md). The assignment of an owner to a group has no bearing on the relationship of owned objects to their owners; groups exist purely as an organizational convenience for administrators.
 
 
 ## Fields
 ## Fields
 
 
 ### Name
 ### Name
 
 
-The name of the group.
+A unique name for the group.
+
+### Description
+
+An optional description of the group's role or membership.

+ 51 - 0
docs/models/users/token.md

@@ -0,0 +1,51 @@
+# Tokens
+
+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; **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
+
+### Version
+
+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
+
+The [user](./user.md) which owns the token. All requests authenticated with the token are performed as this user.
+
+### Description
+
+A free-form description of the token (e.g. naming the application or automation that uses it).
+
+### Created
+
+The date and time at which the token was created.
+
+### Expires
+
+An optional date and time after which the token will no longer be valid. Tokens without an expiration never expire.
+
+### Last Used
+
+The date and time at which the token was most recently used to authenticate a request. This value is updated at most once per minute to limit database write overhead.
+
+### Enabled
+
+When unset, the token is temporarily revoked. Disabled tokens cannot be used to authenticate requests but are not deleted, allowing them to be re-enabled later.
+
+### Write Enabled
+
+When unset, the token may only be used for read operations (e.g. `GET`). All write operations (`POST`, `PATCH`, `PUT`, `DELETE`) made with the token will be rejected.
+
+### Allowed IPs
+
+An optional list of IPv4 and/or IPv6 prefixes from which the token may be used. If set, requests originating from any other source address will be rejected.
+
+### Key (v2 only)
+
+A short, randomly-generated identifier transmitted in plaintext alongside each request. The key allows the server to locate the matching token record before validating the secret portion.
+
+### Plaintext (v1 only)
+
+The full plaintext value of a v1 token. Stored as-is in the database, which is one of the reasons v2 tokens are preferred.

+ 47 - 0
docs/models/users/user.md

@@ -0,0 +1,47 @@
+# Users
+
+A user represents an individual account in NetBox. Users authenticate to access the application, and may be granted permissions either directly or through their assigned [groups](./group.md). Each user can hold one or more API [tokens](./token.md) for use with the REST and GraphQL APIs.
+
+NetBox extends Django's stock user model to support multiple API tokens per user, configurable [object permissions](./objectpermission.md), and integration with [remote authentication backends](../../administration/authentication/overview.md).
+
+## Fields
+
+### Username
+
+A unique identifier used to log in. May contain letters, digits, and the characters `@ . + - _`. Username comparison is case-insensitive: a new user cannot be created whose username differs from an existing one only in letter case.
+
+### First Name
+
+The user's given name. Optional.
+
+### Last Name
+
+The user's family name. Optional.
+
+### Email Address
+
+The user's email address. Used by NetBox to send notifications (e.g. error reports) when configured to do so.
+
+### Active
+
+When unset, the user is treated as inactive and may not log in. Disabling a user is generally preferable to deletion, as it preserves the user's history in change records and other related objects.
+
+### Staff Status
+
+Designates whether the user can log into the (legacy) Django admin site. Most NetBox functionality is exposed via the standard UI; staff status is rarely needed.
+
+### Superuser Status
+
+Designates that the user is granted all permissions implicitly, bypassing all permission checks. Use sparingly.
+
+### Date Joined
+
+The date and time at which the user account was created.
+
+### Groups
+
+The set of [groups](./group.md) to which the user belongs. A user inherits all permissions assigned to each of their groups.
+
+### Object Permissions
+
+The set of [object permissions](./objectpermission.md) assigned directly to the user, in addition to those granted via group membership.

+ 2 - 1
docs/plugins/development/index.md

@@ -116,9 +116,10 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                          |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                          |
 | `queues`              | A list of custom background task queues to create                                                                                  |
 | `queues`              | A list of custom background task queues to create                                                                                  |
 | `events_pipeline`     | A list of handlers to add to [`EVENTS_PIPELINE`](../../configuration/miscellaneous.md#events_pipeline), identified by dotted paths |
 | `events_pipeline`     | A list of handlers to add to [`EVENTS_PIPELINE`](../../configuration/miscellaneous.md#events_pipeline), identified by dotted paths |
-| `search_extensions`   | The dotted path to the list of search index classes (default: `search.indexes`)                                                    |
+| `search_indexes`      | The dotted path to the list of search index classes (default: `search.indexes`)                                                    |
 | `data_backends`       | The dotted path to the list of data source backend classes (default: `data_backends.backends`)                                     |
 | `data_backends`       | The dotted path to the list of data source backend classes (default: `data_backends.backends`)                                     |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)                        |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)                        |
+| `menu`                | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`)                                 |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                                |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                                |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                           |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                           |
 | `user_preferences`    | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`)           |
 | `user_preferences`    | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`)           |

+ 56 - 9
docs/plugins/development/permissions.md

@@ -1,24 +1,71 @@
 # Custom Model Actions
 # Custom Model Actions
 
 
-Plugins can register custom permission actions for their models. These actions appear as checkboxes in the ObjectPermission form, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
+Plugins can register custom permission actions for their models. These actions appear as checkboxes in the [ObjectPermission](../../models/users/objectpermission.md) form alongside the standard view/add/change/delete actions, making it easy for administrators to grant or restrict access to plugin-specific functionality without manually entering action names.
 
 
-For example, a plugin might define a "sync" action for a model that syncs data from an external source, or a "bypass" action that allows users to bypass certain restrictions.
+For example, a plugin might define a `sync` action for a model that fetches data from an external source, or a `bypass` action that allows users to skip certain restrictions.
 
 
 ## Registering Model Actions
 ## Registering Model Actions
 
 
-The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox will automatically register these as model actions when the app is loaded:
+The preferred way to register custom actions is via Django's `Meta.permissions` on the model class. NetBox automatically registers these as model actions when the app is loaded:
 
 
-```python
+```python title="models.py"
 from netbox.models import NetBoxModel
 from netbox.models import NetBoxModel
 
 
-class MyModel(NetBoxModel):
-    # ...
+class WidgetSync(NetBoxModel):
+    # ... fields ...
 
 
     class Meta:
     class Meta:
         permissions = [
         permissions = [
-            ('sync', 'Synchronize data from external source'),
-            ('export', 'Export data to external system'),
+            ('sync', 'Synchronize widgets from external source'),
+            ('export', 'Export widgets to external system'),
         ]
         ]
 ```
 ```
 
 
-Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission.
+Once registered, these actions appear as checkboxes in a flat list when creating or editing an ObjectPermission. The first element of each tuple is the action's identifier (used in code) and the second is the help text shown to administrators in the UI.
+
+!!! note "Reserved action names"
+    Action names that conflict with NetBox's built-in CRUD verbs (`view`, `add`, `change`, `delete`) are reserved and cannot be reused as custom actions.
+
+## Granting an Action
+
+Custom actions are granted just like any standard permission:
+
+1. Open **Admin → Object Permissions** and create a new permission.
+2. Select the relevant object type(s) (e.g. `my_plugin | widget sync`).
+3. Tick the custom action's checkbox (e.g. `sync`).
+4. Assign the permission to the desired users and/or groups.
+
+Optional [constraints](../../models/users/objectpermission.md#constraints) may be added to limit the permission to a subset of objects.
+
+## Checking an Action at Runtime
+
+Custom actions follow Django's standard permission naming convention `<app_label>.<action>_<model>`. To check whether the current user is authorized to perform a custom action against a model, call `user.has_perm()`:
+
+```python
+if request.user.has_perm('my_plugin.sync_widgetsync'):
+    # User is permitted to invoke the sync action
+    ...
+```
+
+Per-object permission checks (which respect any [constraints](../../models/users/objectpermission.md#constraints) on the granting permission) work the same way:
+
+```python
+if request.user.has_perm('my_plugin.sync_widgetsync', obj=widget):
+    ...
+```
+
+For class-based views, NetBox provides `ObjectPermissionRequiredMixin` from `utilities.views`, which integrates cleanly with these custom actions:
+
+```python title="views.py"
+from utilities.views import ObjectPermissionRequiredMixin
+from django.views.generic import View
+
+class SyncWidgetView(ObjectPermissionRequiredMixin, View):
+    queryset = WidgetSync.objects.all()
+    permission_required = 'my_plugin.sync_widgetsync'
+
+    def post(self, request, pk):
+        widget = self.get_object()
+        widget.sync()
+        return redirect(widget.get_absolute_url())
+```

+ 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
 ```
 ```

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

@@ -1,5 +1,88 @@
 # NetBox v4.6
 # NetBox v4.6
 
 
+## v4.6.2 (2026-06-02)
+
+### Enhancements
+
+* [#17127](https://github.com/netbox-community/netbox/issues/17127) - Add a user preference for selecting metric or imperial units of measurement
+* [#19336](https://github.com/netbox-community/netbox/issues/19336) - Convert the filtering of tabbed list views from JavaScript to HTMX
+* [#19460](https://github.com/netbox-community/netbox/issues/19460) - Support additional template variables for greater flexibility when constructing map URLs
+* [#20804](https://github.com/netbox-community/netbox/issues/20804) - Support bulk renaming of the `label` field on device components
+* [#21261](https://github.com/netbox-community/netbox/issues/21261) - Allow setting `quick_add` on an `ObjectVar` in custom scripts
+* [#21952](https://github.com/netbox-community/netbox/issues/21952) - Improve robustness of the RQ worker liveness check
+* [#22109](https://github.com/netbox-community/netbox/issues/22109) - Include child dependency counts in the module type REST API representation
+* [#22212](https://github.com/netbox-community/netbox/issues/22212) - Make designated environment parameters available within Jinja2 templates via the new `env()` filter
+* [#22239](https://github.com/netbox-community/netbox/issues/22239) - Rename the "Save" button on the table configuration form to "Apply" for clarity
+* [#22255](https://github.com/netbox-community/netbox/issues/22255) - Allow plugins to register custom serializer resolvers for `get_serializer_for_model()`
+
+### Bug Fixes
+
+* [#21091](https://github.com/netbox-community/netbox/issues/21091) - Declare proper request & response schema types for the device/VM config rendering API endpoints
+* [#22158](https://github.com/netbox-community/netbox/issues/22158) - Cache empty config revision state to avoid per-request queries polluting database connections
+* [#22163](https://github.com/netbox-community/netbox/issues/22163) - Fix `ValueError` raised by CircuitTerminationForm when a termination type is set but the target object is blank
+* [#22180](https://github.com/netbox-community/netbox/issues/22180) - Ensure custom scripts added via a remote data source are validated
+* [#22187](https://github.com/netbox-community/netbox/issues/22187) - Fix erroneous cable path retracing when using a cable profile
+* [#22219](https://github.com/netbox-community/netbox/issues/22219) - Add missing required form field indicator to InlineFields rows
+* [#22228](https://github.com/netbox-community/netbox/issues/22228) - Validate `vid_ranges` bounds metadata in `VLANGroup.save()` to avoid miscounts and a crash on singleton ranges
+* [#22232](https://github.com/netbox-community/netbox/issues/22232) - Prevent duplicate scheduled background jobs from being created
+* [#22233](https://github.com/netbox-community/netbox/issues/22233) - Fix `site_id` filter on the cables REST API returning no results when both endpoints are circuit terminations
+* [#22247](https://github.com/netbox-community/netbox/issues/22247) - Display the verbose name instead of the internal model name for the related object type on the custom field detail page
+* [#22270](https://github.com/netbox-community/netbox/issues/22270) - Avoid recording a spurious UPDATE change record after DELETE for objects with reverse SET_NULL relations
+* [#22282](https://github.com/netbox-community/netbox/issues/22282) - Fix `fetch()` on S3Backend to reliably resolve object keys
+* [#22283](https://github.com/netbox-community/netbox/issues/22283) - Restrict the Job queryset in ScriptResultView to authorized objects
+* [#22286](https://github.com/netbox-community/netbox/issues/22286) - Mark the `name` and `description` fields on the GraphQL ConfigContextProfileFilter as optional
+* [#22287](https://github.com/netbox-community/netbox/issues/22287) - Fix GraphQL `EventRuleFilter.action_object_type` being typed as a string lookup against a ContentType foreign key
+* [#22301](https://github.com/netbox-community/netbox/issues/22301) - Avoid name conflict when multiple plugins introduce taggable models of the same name
+* [#22307](https://github.com/netbox-community/netbox/issues/22307) - Fix inconsistent enforcement of `grant_token` permissions between the UI and REST API
+* [#22325](https://github.com/netbox-community/netbox/issues/22325) - Fix `AttributeError` when creating a custom field choice set with base choices
+* [#22328](https://github.com/netbox-community/netbox/issues/22328) - Avoid out-of-memory crash in DynamicMultipleChoiceField with large choice sets
+
+---
+
+## 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

+ 6 - 0
mkdocs.yml

@@ -189,6 +189,8 @@ nav:
             - DataFile: 'models/core/datafile.md'
             - DataFile: 'models/core/datafile.md'
             - DataSource: 'models/core/datasource.md'
             - DataSource: 'models/core/datasource.md'
             - Job: 'models/core/job.md'
             - Job: 'models/core/job.md'
+            - ObjectChange: 'models/core/objectchange.md'
+            - ObjectType: 'models/core/objecttype.md'
         - DCIM:
         - DCIM:
             - Cable: 'models/dcim/cable.md'
             - Cable: 'models/dcim/cable.md'
             - CableBundle: 'models/dcim/cablebundle.md'
             - CableBundle: 'models/dcim/cablebundle.md'
@@ -280,8 +282,12 @@ nav:
             - Tenant: 'models/tenancy/tenant.md'
             - Tenant: 'models/tenancy/tenant.md'
             - TenantGroup: 'models/tenancy/tenantgroup.md'
             - TenantGroup: 'models/tenancy/tenantgroup.md'
         - Users:
         - Users:
+            - Group: 'models/users/group.md'
+            - ObjectPermission: 'models/users/objectpermission.md'
             - Owner: 'models/users/owner.md'
             - Owner: 'models/users/owner.md'
             - OwnerGroup: 'models/users/ownergroup.md'
             - OwnerGroup: 'models/users/ownergroup.md'
+            - Token: 'models/users/token.md'
+            - User: 'models/users/user.md'
         - Virtualization:
         - Virtualization:
             - Cluster: 'models/virtualization/cluster.md'
             - Cluster: 'models/virtualization/cluster.md'
             - ClusterGroup: 'models/virtualization/clustergroup.md'
             - ClusterGroup: 'models/virtualization/clustergroup.md'

+ 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')
 
 

+ 24 - 0
netbox/circuits/tests/query_counts.json

@@ -0,0 +1,24 @@
+{
+  "circuit:api_list_objects": 16,
+  "circuit:list_objects_with_permission": 22,
+  "circuitgroup:api_list_objects": 13,
+  "circuitgroup:list_objects_with_permission": 20,
+  "circuitgroupassignment:api_list_objects": 17,
+  "circuitgroupassignment:list_objects_with_permission": 26,
+  "circuittermination:api_list_objects": 18,
+  "circuittermination:list_objects_with_permission": 24,
+  "circuittype:api_list_objects": 13,
+  "circuittype:list_objects_with_permission": 20,
+  "provider:api_list_objects": 15,
+  "provider:list_objects_with_permission": 20,
+  "provideraccount:api_list_objects": 14,
+  "provideraccount:list_objects_with_permission": 21,
+  "providernetwork:api_list_objects": 14,
+  "providernetwork:list_objects_with_permission": 21,
+  "virtualcircuit:api_list_objects": 16,
+  "virtualcircuit:list_objects_with_permission": 24,
+  "virtualcircuittermination:api_list_objects": 17,
+  "virtualcircuittermination:list_objects_with_permission": 23,
+  "virtualcircuittype:api_list_objects": 13,
+  "virtualcircuittype:list_objects_with_permission": 20
+}

+ 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"""
             [
             [

+ 32 - 3
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',
@@ -91,7 +120,7 @@ class CircuitPanel(panels.ObjectAttributesPanel):
     cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
     cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
     type = attrs.RelatedObjectAttr('type', linkify=True, colored=True)
     type = attrs.RelatedObjectAttr('type', linkify=True, colored=True)
     status = attrs.ChoiceAttr('status')
     status = attrs.ChoiceAttr('status')
-    distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+    distance = attrs.DistanceAttr('distance')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
     install_date = attrs.DateTimeAttr('install_date', spec='date')
     install_date = attrs.DateTimeAttr('install_date', spec='date')
     termination_date = attrs.DateTimeAttr('termination_date', spec='date')
     termination_date = attrs.DateTimeAttr('termination_date', spec='date')

+ 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

+ 27 - 12
netbox/core/data_backends.py

@@ -1,9 +1,8 @@
 import logging
 import logging
-import os
 import re
 import re
 import tempfile
 import tempfile
 from contextlib import contextmanager
 from contextlib import contextmanager
-from pathlib import Path
+from pathlib import Path, PurePosixPath
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
 from django import forms
 from django import forms
@@ -171,6 +170,7 @@ class S3Backend(DataBackend):
         import boto3
         import boto3
 
 
         local_path = tempfile.TemporaryDirectory()
         local_path = tempfile.TemporaryDirectory()
+        local_root = Path(local_path.name).resolve()
 
 
         # Initialize the S3 resource and bucket
         # Initialize the S3 resource and bucket
         aws_access_key_id = self.params.get('aws_access_key_id')
         aws_access_key_id = self.params.get('aws_access_key_id')
@@ -185,16 +185,31 @@ class S3Backend(DataBackend):
         )
         )
         bucket = s3.Bucket(self._bucket_name)
         bucket = s3.Bucket(self._bucket_name)
 
 
-        # Download all files within the specified path
-        for obj in bucket.objects.filter(Prefix=self._remote_path):
-            local_filename = os.path.join(local_path.name, obj.key)
-            # Build local path
-            Path(os.path.dirname(local_filename)).mkdir(parents=True, exist_ok=True)
-            bucket.download_file(obj.key, local_filename)
-
-        yield local_path.name
-
-        local_path.cleanup()
+        try:
+            # Download all files within the specified path
+            for obj in bucket.objects.filter(Prefix=self._remote_path):
+                local_filename = self._resolve_local_path(local_root, obj.key)
+                # Build local path
+                local_filename.parent.mkdir(parents=True, exist_ok=True)
+                bucket.download_file(obj.key, str(local_filename))
+
+            yield local_path.name
+        finally:
+            local_path.cleanup()
+
+    @staticmethod
+    def _resolve_local_path(local_root, key):
+        # S3 object keys are POSIX-style paths. Strip any leading separator so the key
+        # joins onto the temp directory rather than replacing it, then ensure the
+        # resolved destination remains within the temp directory to prevent path
+        # traversal via crafted object keys.
+        key_parts = PurePosixPath(key.lstrip('/')).parts
+        local_filename = local_root.joinpath(*key_parts).resolve()
+        if not local_filename.is_relative_to(local_root) or local_filename == local_root:
+            raise SyncError(
+                _("Invalid S3 object key '{key}': resolves outside of the local data directory").format(key=key)
+            )
+        return local_filename
 
 
     @property
     @property
     def _region_name(self):
     def _region_name(self):

+ 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)

+ 16 - 0
netbox/core/signals.py

@@ -225,7 +225,23 @@ def handle_deleted_object(sender, instance, **kwargs):
                 # We only care about triggering the m2m_changed signal for models which support
                 # We only care about triggering the m2m_changed signal for models which support
                 # change logging
                 # change logging
                 continue
                 continue
+            related_object_type = ContentType.objects.get_for_model(related_model)
             for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
             for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
+                # Skip any related object that is itself being deleted as part of this same
+                # operation (e.g. a sibling caught up in the same cascade). Its deletion has
+                # already been recorded, so nulling the FK and saving here would record an
+                # UPDATE ObjectChange *after* the object's DELETE, corrupting the changelog and
+                # breaking branch replay. (Ref: #22270)
+                #
+                # Note this is order-dependent: it only fires once the related object's own
+                # pre_delete has run (adding it to the set). If the cascade happens to delete
+                # this instance *before* the related object, the guard won't trigger and the
+                # related object still gets an UPDATE — but in the harmless UPDATE-then-DELETE
+                # order, not the corrupting DELETE-then-UPDATE order. Fully suppressing it in
+                # every ordering would require the complete deletion set, which isn't available
+                # from a pre_delete signal.
+                if (related_object_type, obj.pk) in _signals_received.pre_delete:
+                    continue
                 obj.snapshot()  # Ensure the change record includes the "before" state
                 obj.snapshot()  # Ensure the change record includes the "before" state
                 if type(relation) is ManyToManyRel:
                 if type(relation) is ManyToManyRel:
                     getattr(obj, related_field_name).remove(instance)
                     getattr(obj, related_field_name).remove(instance)

+ 8 - 0
netbox/core/tests/query_counts.json

@@ -0,0 +1,8 @@
+{
+  "datafile:api_list_objects": 10,
+  "datafile:list_objects_with_permission": 18,
+  "datasource:api_list_objects": 12,
+  "datasource:list_objects_with_permission": 20,
+  "job:api_list_objects": 12,
+  "job:list_objects_with_permission": 19
+}

+ 59 - 16
netbox/core/tests/test_api.py

@@ -1,5 +1,6 @@
 import uuid
 import uuid
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django_rq import get_queue
 from django_rq import get_queue
@@ -10,14 +11,15 @@ from rq.job import JobStatus
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 
 
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
-from users.models import Token, User
+from users.models import Token
 from utilities.testing import APITestCase, APIViewTestCases, TestCase
 from utilities.testing import APITestCase, APIViewTestCases, TestCase
+from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.utils import disable_logging
 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 +28,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 +64,7 @@ class DataSourceTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
-class DataFileTest(
+class DataFileTestCase(
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.GetObjectViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.ListObjectsViewTestCase,
     APIViewTestCases.GraphQLTestCase
     APIViewTestCases.GraphQLTestCase
@@ -105,7 +107,8 @@ class DataFileTest(
         DataFile.objects.bulk_create(data_files)
         DataFile.objects.bulk_create(data_files)
 
 
 
 
-class ObjectTypeTest(APITestCase):
+class ObjectTypeTestCase(APITestCase):
+    model = ObjectType
 
 
     def test_list_objects(self):
     def test_list_objects(self):
         object_type_count = ObjectType.objects.count()
         object_type_count = ObjectType.objects.count()
@@ -121,7 +124,52 @@ class ObjectTypeTest(APITestCase):
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
         self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
 
 
 
 
-class BackgroundTaskTestCase(TestCase):
+class JobTestCase(
+    APIViewTestCases.GetObjectViewTestCase,
+    APIViewTestCases.ListObjectsViewTestCase,
+):
+    model = Job
+    brief_fields = ['completed', 'created', 'status', 'url', 'user']
+
+    @classmethod
+    def setUpTestData(cls):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///var/tmp/source1/',
+        )
+        ct = ContentType.objects.get_for_model(DataSource)
+        Job.objects.bulk_create(
+            [
+                Job(
+                    name='Job 1',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='pending',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+                Job(
+                    name='Job 2',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='running',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+                Job(
+                    name='Job 3',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='completed',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+            ]
+        )
+
+
+class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
     user_permissions = ()
     user_permissions = ()
 
 
     @staticmethod
     @staticmethod
@@ -133,19 +181,14 @@ class BackgroundTaskTestCase(TestCase):
         raise Exception("Job failed")
         raise Exception("Job failed")
 
 
     def setUp(self):
     def setUp(self):
-        """
-        Create a user and token for API calls.
-        """
-        # Create the test user and assign permissions
-        self.user = User.objects.create_user(username='testuser', is_active=True)
+        super().setUp()
+
+        # The base TestCase creates self.user; make it active and create a token for API calls.
+        self.user.is_active = True
+        self.user.save()
         self.token = Token.objects.create(user=self.user)
         self.token = Token.objects.create(user=self.user)
         self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
         self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
 
 
-        # Clear all queues prior to running each test
-        get_queue('default').connection.flushall()
-        get_queue('high').connection.flushall()
-        get_queue('low').connection.flushall()
-
     def test_background_queue_list(self):
     def test_background_queue_list(self):
         url = reverse('core-api:rqqueue-list')
         url = reverse('core-api:rqqueue-list')
 
 

+ 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

+ 52 - 3
netbox/core/tests/test_data_backends.py

@@ -1,9 +1,12 @@
+import tempfile
+from pathlib import Path
 from unittest import skipIf
 from unittest import skipIf
 from unittest.mock import patch
 from unittest.mock import patch
 
 
 from django.test import TestCase
 from django.test import TestCase
 
 
-from core.data_backends import url_has_embedded_credentials
+from core.data_backends import S3Backend, url_has_embedded_credentials
+from core.exceptions import SyncError
 
 
 try:
 try:
     import dulwich  # noqa: F401
     import dulwich  # noqa: F401
@@ -12,7 +15,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 +57,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.
 
 
@@ -114,3 +117,49 @@ class GitBackendCredentialIntegrationTests(TestCase):
 
 
         self.assertEqual(kwargs.get('username'), None)
         self.assertEqual(kwargs.get('username'), None)
         self.assertEqual(kwargs.get('password'), None)
         self.assertEqual(kwargs.get('password'), None)
+
+
+class S3BackendKeyResolutionTestCase(TestCase):
+    """
+    Verify that S3Backend resolves object keys to local paths safely, rejecting any
+    key that would write outside of the local temporary directory.
+    """
+
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(self._tmp.cleanup)
+        self.local_root = Path(self._tmp.name).resolve()
+
+    def test_simple_key_resolves_under_root(self):
+        result = S3Backend._resolve_local_path(self.local_root, 'data/file.yaml')
+        self.assertEqual(result, self.local_root / 'data' / 'file.yaml')
+
+    def test_nested_key_resolves_under_root(self):
+        result = S3Backend._resolve_local_path(self.local_root, 'a/b/c/d.txt')
+        self.assertEqual(result, self.local_root / 'a' / 'b' / 'c' / 'd.txt')
+
+    def test_absolute_key_is_contained(self):
+        # A key beginning with '/' must not escape the local root
+        result = S3Backend._resolve_local_path(self.local_root, '/etc/passwd')
+        self.assertEqual(result, self.local_root / 'etc' / 'passwd')
+        self.assertTrue(result.is_relative_to(self.local_root))
+
+    def test_parent_traversal_is_rejected(self):
+        with self.assertRaises(SyncError):
+            S3Backend._resolve_local_path(self.local_root, '../escape.txt')
+
+    def test_nested_parent_traversal_is_rejected(self):
+        with self.assertRaises(SyncError):
+            S3Backend._resolve_local_path(self.local_root, 'foo/../../escape.txt')
+
+    def test_absolute_parent_traversal_is_rejected(self):
+        with self.assertRaises(SyncError):
+            S3Backend._resolve_local_path(self.local_root, '/../escape.txt')
+
+    def test_empty_key_is_rejected(self):
+        with self.assertRaises(SyncError):
+            S3Backend._resolve_local_path(self.local_root, '')
+
+    def test_root_key_is_rejected(self):
+        with self.assertRaises(SyncError):
+            S3Backend._resolve_local_path(self.local_root, '/')

+ 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):
         """
         """

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

@@ -0,0 +1,547 @@
+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.choices import InterfaceTypeChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, 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'])
+
+    def test_cascade_delete_does_not_record_update_after_delete(self):
+        # Regression test for #22270. Deleting a device cascades to all of its interfaces.
+        # When the LAG interface's pre_delete fires, it clears the `lag` FK (SET_NULL) on its
+        # member interfaces and records a change. If a member is itself being deleted in the
+        # same cascade, that update must not be written *after* the member's delete record —
+        # an UPDATE-after-DELETE corrupts the changelog and breaks branch replay.
+        manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
+        role = DeviceRole.objects.create(name='Role', slug='role')
+        site = Site.objects.create(name='Site', slug='site')
+        device = Device.objects.create(name='Device', site=site, device_type=device_type, role=role)
+        # Members are created before the LAG so their PKs are lower; the cascade fires their
+        # pre_delete (and records their DELETE) before the LAG's pre_delete runs.
+        member1 = Interface.objects.create(device=device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        member2 = Interface.objects.create(device=device, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        lag = Interface.objects.create(device=device, name='lag0', type=InterfaceTypeChoices.TYPE_LAG)
+        member1.lag = lag
+        member1.save()
+        member2.lag = lag
+        member2.save()
+        member_pks = (member1.pk, member2.pk)
+        interface_type = ContentType.objects.get_for_model(Interface)
+
+        request = _build_request(self.user)
+        with event_tracking(request):
+            device.delete()
+
+        for member_pk in member_pks:
+            actions = list(
+                ObjectChange.objects.filter(
+                    request_id=request.id,
+                    changed_object_type=interface_type,
+                    changed_object_id=member_pk,
+                ).order_by('time', 'pk').values_list('action', flat=True)
+            )
+            # In this ordering the member's pre_delete fires before the LAG's, so the LAG
+            # skips it: the member's only change record for the request is its own deletion.
+            # Pre-fix, a spurious UPDATE was appended *after* this DELETE — assert the exact
+            # sequence so any trailing (or leading) update fails the test.
+            self.assertEqual(actions, [ObjectChangeActionChoices.ACTION_DELETE])
+
+
+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

+ 49 - 6
netbox/core/tests/test_views.py

@@ -3,6 +3,7 @@ import urllib.parse
 import uuid
 import uuid
 from datetime import datetime
 from datetime import datetime
 
 
+from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django_rq import get_queue
 from django_rq import get_queue
@@ -17,6 +18,7 @@ from core.models import *
 from dcim.models import Site
 from dcim.models import Site
 from users.models import User
 from users.models import User
 from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
 from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
+from utilities.testing.mixins import RQQueueTestMixin
 
 
 
 
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -104,6 +106,52 @@ class DataFileTestCase(
         DataFile.objects.bulk_create(data_files)
         DataFile.objects.bulk_create(data_files)
 
 
 
 
+class JobTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+    model = Job
+
+    @classmethod
+    def setUpTestData(cls):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='local',
+            source_url='file:///var/tmp/source1/',
+        )
+        ct = ContentType.objects.get_for_model(DataSource)
+        Job.objects.bulk_create(
+            [
+                Job(
+                    name='Job 1',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='pending',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+                Job(
+                    name='Job 2',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='running',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+                Job(
+                    name='Job 3',
+                    object_type=ct,
+                    object_id=datasource.pk,
+                    status='completed',
+                    queue_name='default',
+                    job_id=uuid.uuid4(),
+                ),
+            ]
+        )
+
+
 # TODO: Convert to StandardTestCases.Views
 # TODO: Convert to StandardTestCases.Views
 class ObjectChangeTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
     user_permissions = (
     user_permissions = (
@@ -141,7 +189,7 @@ class ObjectChangeTestCase(TestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
 
 
-class BackgroundTaskTestCase(TestCase):
+class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
     user_permissions = ()
     user_permissions = ()
 
 
     # Dummy worker functions
     # Dummy worker functions
@@ -163,11 +211,6 @@ class BackgroundTaskTestCase(TestCase):
         self.user.is_active = True
         self.user.is_active = True
         self.user.save()
         self.user.save()
 
 
-        # Clear all queues prior to running each test
-        get_queue('default').connection.flushall()
-        get_queue('high').connection.flushall()
-        get_queue('low').connection.flushall()
-
     def test_background_queue_list(self):
     def test_background_queue_list(self):
         url = reverse('core:background_queue_list')
         url = reverse('core:background_queue_list')
 
 

+ 3 - 2
netbox/core/views.py

@@ -14,7 +14,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 from django.views.generic import View
-from django_rq.queues import get_connection, get_queue_by_index, get_redis_connection
+from django_rq.queues import get_queue_by_index, get_redis_connection
 from django_rq.settings import get_queues_list, get_queues_map
 from django_rq.settings import get_queues_list, get_queues_map
 from django_rq.utils import get_statistics
 from django_rq.utils import get_statistics
 from rq.exceptions import NoSuchJobError
 from rq.exceptions import NoSuchJobError
@@ -55,6 +55,7 @@ from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.htmx import htmx_partial
 from utilities.json import ConfigJSONEncoder
 from utilities.json import ConfigJSONEncoder
 from utilities.query import count_related
 from utilities.query import count_related
+from utilities.rqworker import get_all_workers
 from utilities.views import (
 from utilities.views import (
     ContentTypePermissionRequiredMixin,
     ContentTypePermissionRequiredMixin,
     GetRelatedModelsMixin,
     GetRelatedModelsMixin,
@@ -707,7 +708,7 @@ class SystemView(UserPassesTestMixin, View):
             'postgresql_version': psql_version,
             'postgresql_version': psql_version,
             'database_name': db_name,
             'database_name': db_name,
             'database_size': db_size,
             'database_size': db_size,
-            'rq_worker_count': Worker.count(get_connection('default')),
+            'rq_worker_count': len(get_all_workers()),
         }
         }
 
 
     def _get_object_counts(self):
     def _get_object_counts(self):

+ 14 - 1
netbox/dcim/api/serializers_/devicetypes.py

@@ -101,11 +101,24 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
     )
     )
     module_count = serializers.IntegerField(read_only=True)
     module_count = serializers.IntegerField(read_only=True)
 
 
+    # Counter fields
+    console_port_template_count = serializers.IntegerField(read_only=True)
+    console_server_port_template_count = serializers.IntegerField(read_only=True)
+    power_port_template_count = serializers.IntegerField(read_only=True)
+    power_outlet_template_count = serializers.IntegerField(read_only=True)
+    interface_template_count = serializers.IntegerField(read_only=True)
+    front_port_template_count = serializers.IntegerField(read_only=True)
+    rear_port_template_count = serializers.IntegerField(read_only=True)
+    module_bay_template_count = serializers.IntegerField(read_only=True)
+
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
             'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
             'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'module_count',
+            'created', 'last_updated', 'module_count', 'console_port_template_count',
+            'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count',
+            'interface_template_count', 'front_port_template_count', 'rear_port_template_count',
+            'module_bay_template_count',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')
         brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')

+ 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

+ 80 - 0
netbox/dcim/filtersets.py

@@ -862,6 +862,10 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
         method='_pass_through_ports',
         method='_pass_through_ports',
         label=_('Has pass-through ports'),
         label=_('Has pass-through ports'),
     )
     )
+    module_bays = django_filters.BooleanFilter(
+        method='_module_bays',
+        label=_('Has module bays'),
+    )
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
@@ -869,6 +873,14 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
             'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
             'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
 
 
             # Counters
             # Counters
+            'console_port_template_count',
+            'console_server_port_template_count',
+            'power_port_template_count',
+            'power_outlet_template_count',
+            'interface_template_count',
+            'front_port_template_count',
+            'rear_port_template_count',
+            'module_bay_template_count',
             'module_count',
             'module_count',
         )
         )
 
 
@@ -904,6 +916,9 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
             rearporttemplates__isnull=value
             rearporttemplates__isnull=value
         )
         )
 
 
+    def _module_bays(self, queryset, name, value):
+        return queryset.exclude(modulebaytemplates__isnull=value)
+
 
 
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -2806,12 +2821,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):

+ 8 - 1
netbox/dcim/forms/filtersets.py

@@ -710,7 +710,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
         ),
         ),
         FieldSet(
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
-            'pass_through_ports', name=_('Components')
+            'pass_through_ports', 'module_bays', name=_('Components')
         ),
         ),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('weight', 'weight_unit', name=_('Weight')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
         FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
@@ -778,6 +778,13 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    module_bays = forms.NullBooleanField(
+        required=False,
+        label=_('Has module bays'),
+        widget=forms.Select(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
     airflow = forms.MultipleChoiceField(
     airflow = forms.MultipleChoiceField(
         label=_('Airflow'),
         label=_('Airflow'),

+ 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'),

+ 63 - 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
 
 
@@ -632,6 +644,14 @@ class ModuleTypeProfileType(PrimaryObjectType):
 )
 )
 class ModuleTypeType(PrimaryObjectType):
 class ModuleTypeType(PrimaryObjectType):
     module_count: BigInt
     module_count: BigInt
+    console_port_template_count: BigInt
+    console_server_port_template_count: BigInt
+    power_port_template_count: BigInt
+    power_outlet_template_count: BigInt
+    interface_template_count: BigInt
+    front_port_template_count: BigInt
+    rear_port_template_count: BigInt
+    module_bay_template_count: BigInt
     profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 
@@ -864,15 +884,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 +928,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 +961,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'),
+        ),
+    ]

+ 35 - 0
netbox/dcim/migrations/0235_cabletermination_circuit_site_cache.py

@@ -0,0 +1,35 @@
+from django.db import migrations
+from django.db.models import OuterRef, Subquery
+
+
+def populate_circuit_termination_site_cache(apps, schema_editor):
+    """
+    Populate the cached _site and _location fields on CableTermination records whose
+    termination is a CircuitTermination. Earlier versions failed to cache these values,
+    causing site/location filters on cables to omit such terminations.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    CableTermination = apps.get_model('dcim', 'CableTermination')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+
+    try:
+        ct = ContentType.objects.get_by_natural_key('circuits', 'circuittermination')
+    except ContentType.DoesNotExist:
+        return
+
+    circuit_terminations = CircuitTermination.objects.filter(pk=OuterRef('termination_id'))
+    CableTermination.objects.filter(termination_type=ct).update(
+        _site=Subquery(circuit_terminations.values('_site_id')[:1]),
+        _location=Subquery(circuit_terminations.values('_location_id')[:1]),
+    )
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0234_cablepath_nodes_index'),
+        ('circuits', '0057_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.RunPython(populate_circuit_termination_site_cache, migrations.RunPython.noop),
+    ]

+ 99 - 0
netbox/dcim/migrations/0236_moduletype_component_counts.py

@@ -0,0 +1,99 @@
+from django.db import migrations
+from django.db.models import Count, OuterRef, Subquery
+
+import utilities.fields
+
+
+def populate_module_type_component_counts(apps, schema_editor):
+    """
+    Populate the component template counter fields for existing ModuleTypes.
+    """
+    ModuleType = apps.get_model('dcim', 'ModuleType')
+    db_alias = schema_editor.connection.alias
+
+    counters = {
+        'console_port_template_count': 'consoleporttemplates',
+        'console_server_port_template_count': 'consoleserverporttemplates',
+        'power_port_template_count': 'powerporttemplates',
+        'power_outlet_template_count': 'poweroutlettemplates',
+        'interface_template_count': 'interfacetemplates',
+        'front_port_template_count': 'frontporttemplates',
+        'rear_port_template_count': 'rearporttemplates',
+        'module_bay_template_count': 'modulebaytemplates',
+    }
+
+    for field_name, related_name in counters.items():
+        count_subquery = (
+            ModuleType.objects.using(db_alias)
+            .filter(pk=OuterRef('pk'))
+            .annotate(_count=Count(related_name))
+            .values('_count')
+        )
+        ModuleType.objects.using(db_alias).update(**{field_name: Subquery(count_subquery)})
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0235_cabletermination_circuit_site_cache'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='moduletype',
+            name='console_port_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.ConsolePortTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='console_server_port_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.ConsoleServerPortTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='front_port_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.FrontPortTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='interface_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.InterfaceTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='module_bay_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.ModuleBayTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='power_outlet_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.PowerOutletTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='power_port_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.PowerPortTemplate'
+            ),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='rear_port_template_count',
+            field=utilities.fields.CounterCacheField(
+                default=0, editable=False, to_field='module_type', to_model='dcim.RearPortTemplate'
+            ),
+        ),
+        migrations.RunPython(populate_module_type_component_counts, migrations.RunPython.noop),
+    ]

+ 48 - 6
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.
@@ -663,9 +690,10 @@ class CableTermination(ChangeLoggedModel):
             self._location = self.termination.rack.location
             self._location = self.termination.rack.location
             self._site = self.termination.rack.site
             self._site = self.termination.rack.site
 
 
-        # Circuit terminations
-        elif getattr(self.termination, 'site', None):
-            self._site = self.termination.site
+        # Circuit terminations (which cache their own site/location)
+        elif self.termination._meta.label_lower == 'circuits.circuittermination':
+            self._site = self.termination._site
+            self._location = self.termination._location
     cache_related_objects.alters_data = True
     cache_related_objects.alters_data = True
 
 
     def to_objectchange(self, action):
     def to_objectchange(self, action):
@@ -730,6 +758,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')
 
 
@@ -901,7 +934,17 @@ class CablePath(models.Model):
                 # Profile-based tracing
                 # Profile-based tracing
                 if links[0].profile:
                 if links[0].profile:
                     cable_profile = links[0].profile_class()
                     cable_profile = links[0].profile_class()
-                    positions = position_stack.pop() if position_stack else [None]
+                    if position_stack:
+                        positions = position_stack.pop()
+                    else:
+                        # When the position stack is empty (e.g. the trace reached this
+                        # profiled cable after crossing single-position pass-through ports
+                        # which don't push onto the stack), derive positions from each
+                        # termination's own cable_positions — which were set by this
+                        # profiled cable when it was saved.
+                        positions = [
+                            pos for term in terminations for pos in (term.cable_positions or [])
+                        ]
                     remote_terminations = []
                     remote_terminations = []
                     new_positions = []
                     new_positions = []
 
 
@@ -918,9 +961,8 @@ class CablePath(models.Model):
                                     remaining[cp] -= 1
                                     remaining[cp] -= 1
 
 
                     # Fallback for when positions don't match cable_positions
                     # Fallback for when positions don't match cable_positions
-                    # (e.g., empty position stack yielding [None])
                     if not term_position_pairs:
                     if not term_position_pairs:
-                        term_position_pairs = [(terminations[0], pos) for pos in positions]
+                        term_position_pairs = [(terminations[0], pos) for pos in positions or [None]]
 
 
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     seen = set()
                     seen = set()

+ 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

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

@@ -58,8 +58,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
     """
     """
     A ModuleType represents a hardware element that can be installed within a device and which houses additional
     A ModuleType represents a hardware element that can be installed within a device and which houses additional
     components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
     components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
-    DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
-    cannot, however house device bays or module bays.
+    DeviceType, each ModuleType can have console, power, interface, pass-through port, and module bay templates assigned
+    to it. It cannot, however, house device bays.
     """
     """
     profile = models.ForeignKey(
     profile = models.ForeignKey(
         to='dcim.ModuleTypeProfile',
         to='dcim.ModuleTypeProfile',
@@ -100,6 +100,40 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         to_field='module_type'
         to_field='module_type'
     )
     )
 
 
+    # Counter fields
+    console_port_template_count = CounterCacheField(
+        to_model='dcim.ConsolePortTemplate',
+        to_field='module_type'
+    )
+    console_server_port_template_count = CounterCacheField(
+        to_model='dcim.ConsoleServerPortTemplate',
+        to_field='module_type'
+    )
+    power_port_template_count = CounterCacheField(
+        to_model='dcim.PowerPortTemplate',
+        to_field='module_type'
+    )
+    power_outlet_template_count = CounterCacheField(
+        to_model='dcim.PowerOutletTemplate',
+        to_field='module_type'
+    )
+    interface_template_count = CounterCacheField(
+        to_model='dcim.InterfaceTemplate',
+        to_field='module_type'
+    )
+    front_port_template_count = CounterCacheField(
+        to_model='dcim.FrontPortTemplate',
+        to_field='module_type'
+    )
+    rear_port_template_count = CounterCacheField(
+        to_model='dcim.RearPortTemplate',
+        to_field='module_type'
+    )
+    module_bay_template_count = CounterCacheField(
+        to_model='dcim.ModuleBayTemplate',
+        to_field='module_type'
+    )
+
     clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
     clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
     prerequisite_models = (
     prerequisite_models = (
         'dcim.Manufacturer',
         'dcim.Manufacturer',

+ 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:

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff