Преглед изворни кода

Merge branch 'main' into 22081-token

Arthur пре 2 недеља
родитељ
комит
18f11aceaf
100 измењених фајлова са 3672 додато и 423 уклоњено
  1. 45 0
      .claude/skills/README.md
  2. 410 0
      .claude/skills/add-model-field/SKILL.md
  3. 519 0
      .claude/skills/add-model/SKILL.md
  4. 92 0
      .claude/skills/run-tests/SKILL.md
  5. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  6. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  7. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  8. 32 12
      .github/workflows/ci.yml
  9. 52 19
      .gitignore
  10. 3 3
      .pre-commit-config.yaml
  11. 6 6
      .readthedocs.yaml
  12. 299 0
      AGENTS.md
  13. 1 87
      CLAUDE.md
  14. 4 1
      CONTRIBUTING.md
  15. 11 5
      base_requirements.txt
  16. 652 110
      contrib/openapi.json
  17. 0 18
      docs/_theme/partials/copyright.html
  18. 3 1
      docs/administration/permissions.md
  19. 3 3
      docs/configuration/development.md
  20. 4 4
      docs/configuration/error-reporting.md
  21. 1 0
      docs/configuration/index.md
  22. 29 0
      docs/configuration/miscellaneous.md
  23. 1 1
      docs/configuration/required-parameters.md
  24. 6 3
      docs/configuration/security.md
  25. 7 0
      docs/configuration/system.md
  26. 1 0
      docs/customization/custom-fields.md
  27. 14 0
      docs/customization/custom-scripts.md
  28. 3 0
      docs/development/application-registry.md
  29. 1 1
      docs/development/getting-started.md
  30. 3 0
      docs/development/models.md
  31. 1 1
      docs/development/release-checklist.md
  32. 0 4
      docs/extra.css
  33. 6 0
      docs/features/context-data.md
  34. 19 2
      docs/features/devices-cabling.md
  35. 7 1
      docs/features/facilities.md
  36. 27 9
      docs/features/virtualization.md
  37. 3 0
      docs/installation/1-postgresql.md
  38. 3 1
      docs/installation/index.md
  39. 4 1
      docs/installation/upgrading.md
  40. 97 1
      docs/integrations/rest-api.md
  41. 18 2
      docs/integrations/webhooks.md
  42. 3 1
      docs/introduction.md
  43. 43 0
      docs/models/core/objectchange.md
  44. 26 0
      docs/models/core/objecttype.md
  45. 15 0
      docs/models/dcim/cablebundle.md
  46. 5 0
      docs/models/dcim/devicebay.md
  47. 12 0
      docs/models/dcim/devicetype.md
  48. 17 3
      docs/models/dcim/macaddress.md
  49. 6 1
      docs/models/dcim/modulebay.md
  50. 30 0
      docs/models/dcim/moduletype.md
  51. 16 4
      docs/models/dcim/moduletypeprofile.md
  52. 5 1
      docs/models/dcim/rack.md
  53. 15 0
      docs/models/dcim/rackgroup.md
  54. 4 0
      docs/models/dcim/rackreservation.md
  55. 17 3
      docs/models/extras/configcontextprofile.md
  56. 6 0
      docs/models/extras/customfield.md
  57. 22 0
      docs/models/extras/customfieldchoiceset.md
  58. 4 1
      docs/models/extras/webhook.md
  59. 4 0
      docs/models/ipam/asn.md
  60. 4 0
      docs/models/ipam/vlangroup.md
  61. 19 0
      docs/models/users/group.md
  62. 50 0
      docs/models/users/objectpermission.md
  63. 6 2
      docs/models/users/ownergroup.md
  64. 51 0
      docs/models/users/token.md
  65. 47 0
      docs/models/users/user.md
  66. 29 13
      docs/models/virtualization/virtualmachine.md
  67. 27 0
      docs/models/virtualization/virtualmachinetype.md
  68. 2 1
      docs/plugins/development/index.md
  69. 71 0
      docs/plugins/development/permissions.md
  70. 69 28
      docs/plugins/development/ui-components.md
  71. 6 0
      docs/plugins/development/webhooks.md
  72. 8 0
      docs/release-notes/index.md
  73. 133 0
      docs/release-notes/version-4.6.md
  74. 12 0
      mkdocs.yml
  75. 10 0
      netbox/account/views.py
  76. 35 0
      netbox/circuits/migrations/0057_default_ordering_indexes.py
  77. 6 0
      netbox/circuits/models/circuits.py
  78. 6 0
      netbox/circuits/models/virtual_circuits.py
  79. 23 7
      netbox/circuits/ui/panels.py
  80. 6 5
      netbox/circuits/views.py
  81. 2 1
      netbox/core/api/serializers_/jobs.py
  82. 1 1
      netbox/core/apps.py
  83. 29 1
      netbox/core/checks.py
  84. 12 0
      netbox/core/choices.py
  85. 3 2
      netbox/core/forms/model_forms.py
  86. 41 8
      netbox/core/jobs.py
  87. 21 0
      netbox/core/migrations/0022_default_ordering_indexes.py
  88. 15 0
      netbox/core/migrations/0023_datasource_sync_permission.py
  89. 16 0
      netbox/core/migrations/0024_job_notifications.py
  90. 13 13
      netbox/core/models/change_logging.py
  91. 3 0
      netbox/core/models/config.py
  92. 3 0
      netbox/core/models/data.py
  93. 22 8
      netbox/core/models/jobs.py
  94. 0 1
      netbox/core/models/object_types.py
  95. 8 0
      netbox/core/tables/jobs.py
  96. 104 1
      netbox/core/tests/test_changelog.py
  97. 88 1
      netbox/core/tests/test_models.py
  98. 7 9
      netbox/core/views.py
  99. 16 2
      netbox/dcim/api/serializers_/cables.py
  100. 8 6
      netbox/dcim/api/serializers_/device_components.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.

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

+ 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:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.10
+      placeholder: v4.6.0
     validations:
       required: true
   - type: dropdown

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

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

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

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

+ 32 - 12
.github/workflows/ci.yml

@@ -1,3 +1,4 @@
+---
 name: CI
 
 on:
@@ -26,6 +27,9 @@ concurrency:
 
 jobs:
   build:
+    name: >-
+      Tests (Python ${{ matrix.python-version }},
+      Node ${{ matrix.node-version }}${{ matrix.coverage && ', coverage' || '' }})
     runs-on: ubuntu-latest
     env:
       NETBOX_CONFIGURATION: netbox.configuration_testing
@@ -33,6 +37,12 @@ jobs:
       matrix:
         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'
+            node-version: '20.x'
+            coverage: true
     services:
       redis:
         image: redis
@@ -53,35 +63,35 @@ jobs:
 
     steps:
     - name: Check out repo
-      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
 
     - name: Check Python linting & PEP8 compliance
-      uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
+      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
+      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
+      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
+      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
 
@@ -92,7 +102,7 @@ jobs:
         pip install coverage tblib
 
     - name: Build documentation
-      run: mkdocs build
+      run: zensical build
 
     - name: Collect static files
       run: python netbox/manage.py collectstatic --no-input
@@ -102,12 +112,22 @@ jobs:
 
     - 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
+      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
-      run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*'
+      if: ${{ matrix.coverage }}
+      run: coverage report

+ 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-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/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/.gitkeep
+
+# Legacy custom reports; REPORTS_ROOT defaults to netbox/reports/.
+# Keep the package marker while ignoring deployment-specific reports.
 /netbox/reports/*
 !/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/__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
-local_requirements.txt
-local_settings.py
 !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
-.idea
-.coverage
-.vscode
+.idea/
+.vscode/
 .python-version

+ 3 - 3
.pre-commit-config.yaml

@@ -21,11 +21,11 @@ repos:
       language: system
       pass_filenames: false
       types: [python]
-    - id: mkdocs-build
+    - id: zensical-build
       name: "Build documentation"
-      description: "Build the documentation with mkdocs"
+      description: "Build the documentation with Zensical"
       files: 'docs/'
-      entry: mkdocs build
+      entry: zensical build
       language: system
       pass_filenames: false
     - id: yarn-validate

+ 6 - 6
.readthedocs.yaml

@@ -1,10 +1,10 @@
 version: 2
 build:
-  os: ubuntu-22.04
+  os: ubuntu-24.04
   tools:
     python: "3.12"
-mkdocs:
-  configuration: mkdocs.yml
-python:
-   install:
-   - requirements: requirements.txt
+  commands:
+    - pip install -r requirements.txt
+    - python -m zensical build --config-file mkdocs.yml
+    - mkdir -p $READTHEDOCS_OUTPUT/html/
+    - cp -r netbox/project-static/docs/* $READTHEDOCS_OUTPUT/html/

+ 299 - 0
AGENTS.md

@@ -0,0 +1,299 @@
+# 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.
+
+## 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 - 1
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.
 
 > [!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
 

+ 11 - 5
base_requirements.txt

@@ -4,7 +4,7 @@ colorama
 
 # The Python web framework on which NetBox is built
 # https://docs.djangoproject.com/en/stable/releases/
-Django==5.2.*
+Django==6.0.*
 
 # Django middleware which permits cross-domain API requests
 # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -35,7 +35,9 @@ django-pglocks
 
 # Prometheus metrics library for Django
 # https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md
-django-prometheus
+# TODO: 2.4.1 is incompatible with Django>=6.0, but a fixed release is expected
+# https://github.com/django-commons/django-prometheus/issues/494
+django-prometheus>=2.4.0,<2.5.0,!=2.4.1
 
 # Django caching backend using Redis
 # https://github.com/jazzband/django-redis/blob/master/CHANGELOG.rst
@@ -69,7 +71,7 @@ django-timezone-field
 # A REST API framework for Django projects
 # https://www.django-rest-framework.org/community/release-notes/
 # TODO: Re-evaluate the monkey-patch of get_unique_validators() before upgrading
-djangorestframework==3.16.1
+djangorestframework==3.17.1
 
 # Sane and flexible OpenAPI 3 schema generation for Django REST framework.
 # https://github.com/tfranzel/drf-spectacular/blob/master/CHANGELOG.rst
@@ -99,8 +101,8 @@ jsonschema
 # https://python-markdown.github.io/changelog/
 Markdown
 
-# MkDocs
-# https://github.com/mkdocs/mkdocs/releases
+# Retain MkDocs 1.x for mkdocstrings
+# https://github.com/mkdocs/mkdocs
 mkdocs<2.0
 
 # MkDocs Material theme (for documentation build)
@@ -177,3 +179,7 @@ tablib
 # Timezone data (required by django-timezone-field on Python 3.9+)
 # https://github.com/python/tzdata/blob/master/NEWS.md
 tzdata
+
+# Documentation builder (succeeds mkdocs)
+# https://github.com/zensical/zensical
+zensical

Разлика између датотеке није приказан због своје велике величине
+ 652 - 110
contrib/openapi.json


+ 0 - 18
docs/_theme/partials/copyright.html

@@ -1,18 +0,0 @@
-<div class="md-copyright">
-  {% if config.copyright %}
-    <div class="md-copyright__highlight">
-      {{ config.copyright }}
-    </div>
-  {% endif %}
-  {% if not config.extra.generator == false %}
-    Made with
-    <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
-      Material for MkDocs
-    </a>
-  {% endif %}
-</div>
-{% if not config.extra.build_public %}
-  <div class="md-copyright">
-    ℹ️ Documentation is being served locally
-  </div>
-{% endif %}

+ 3 - 1
docs/administration/permissions.md

@@ -20,7 +20,9 @@ There are four core actions that can be permitted for each type of object within
 * **Change** - Modify an existing object
 * **Delete** - Delete an existing object
 
-In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
+In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `sync` action for data sources allows a user to synchronize data from a remote source, and the `render_config` action for devices and virtual machines allows rendering configuration templates.
+
+Some models have registered actions that appear as checkboxes in the "Actions" section when creating or editing a permission. These are shown in a flat list alongside the built-in CRUD actions. Additional actions (such as those not yet registered by a plugin, or for backwards compatibility) can be entered manually in the "Additional actions" field.
 
 !!! note
     Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.

+ 3 - 3
docs/configuration/development.md

@@ -4,9 +4,9 @@
 
 Default: `False`
 
-This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
-clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
-interface.
+This setting enables debugging and displays a debugging toolbar in the user interface. Debugging should be enabled only during development or troubleshooting.
+
+Note that the debugging toolbar will be displayed only for requests originating from [internal IP addresses](./system.md#internal_ips), if defined. If no internal IPs are defined, the toolbar will be displayed for all requests.
 
 !!! warning
     Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a

+ 4 - 4
docs/configuration/error-reporting.md

@@ -18,7 +18,7 @@ Additionally, `http_proxy` and `https_proxy` are set to the HTTP and HTTPS proxi
 
 ## SENTRY_DSN
 
-!!! warning "This parameter will be removed in NetBox v4.5."
+!!! warning "This parameter will be removed in NetBox v4.7."
     Set this using `SENTRY_CONFIG` instead:
 
     ```
@@ -50,7 +50,7 @@ Set to `True` to enable automatic error reporting via [Sentry](https://sentry.io
 
 ## SENTRY_SAMPLE_RATE
 
-!!! warning "This parameter will be removed in NetBox v4.5."
+!!! warning "This parameter will be removed in NetBox v4.7."
     Set this using `SENTRY_CONFIG` instead:
 
     ```
@@ -67,7 +67,7 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
 
 ## SENTRY_SEND_DEFAULT_PII
 
-!!! warning "This parameter will be removed in NetBox v4.5."
+!!! warning "This parameter will be removed in NetBox v4.7."
     Set this using `SENTRY_CONFIG` instead:
 
     ```
@@ -103,7 +103,7 @@ SENTRY_TAGS = {
 
 ## SENTRY_TRACES_SAMPLE_RATE
 
-!!! warning "This parameter will be removed in NetBox v4.5."
+!!! warning "This parameter will be removed in NetBox v4.7."
     Set this using `SENTRY_CONFIG` instead:
 
     ```

+ 1 - 0
docs/configuration/index.md

@@ -21,6 +21,7 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
 * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
 * [`BANNER_LOGIN`](./miscellaneous.md#banner_login)
 * [`BANNER_TOP`](./miscellaneous.md#banner_top)
+* [`CHANGELOG_RETAIN_CREATE_LAST_UPDATE`](./miscellaneous.md#changelog_retain_create_last_update)
 * [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention)
 * [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators)
 * [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences)

+ 29 - 0
docs/configuration/miscellaneous.md

@@ -73,6 +73,23 @@ This data enables the project maintainers to estimate how many NetBox deployment
 
 ---
 
+## CHANGELOG_RETAIN_CREATE_LAST_UPDATE
+
+!!! tip "Dynamic Configuration Parameter"
+
+Default: `False`
+
+When pruning expired changelog entries (per `CHANGELOG_RETENTION`), retain each non-deleted object's original `create`
+change record and its most recent `update` change record. If an object has a `delete` change record, its changelog
+entries are pruned normally according to `CHANGELOG_RETENTION`.
+
+!!! note
+    For objects without a `delete` change record, the original `create` record and most recent `update` record are
+    exempt from pruning. All other changelog records (including intermediate `update` records and all `delete` records)
+    remain subject to pruning per `CHANGELOG_RETENTION`.
+
+---
+
 ## CHANGELOG_RETENTION
 
 !!! tip "Dynamic Configuration Parameter"
@@ -106,6 +123,18 @@ The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` da
 
 ---
 
+## STREAMING_EXPORTS
+
+!!! note "This parameter was introduced in NetBox v4.6."
+
+Default: `False`
+
+When set to `True`, CSV bulk exports are returned as a streaming HTTP response, emitting rows to the client as they are rendered rather than buffering the entire dataset in memory first. This can significantly reduce memory usage and time-to-first-byte for very large exports.
+
+Because streaming responses do not have a `Content-Length` header and defer errors until after the response has begun, this behavior is opt-in.
+
+---
+
 ## ENFORCE_GLOBAL_UNIQUE
 
 !!! tip "Dynamic Configuration Parameter"

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

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

+ 6 - 3
docs/configuration/security.md

@@ -153,7 +153,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
 
 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.
 
@@ -161,6 +161,9 @@ Note that enabling this setting causes NetBox to update a user's session in the
 
 ## LOGIN_REQUIRED
 
+!!! warning "Legacy Configuration Parameter"
+    The `LOGIN_REQUIRED` configuration parameter is deprecated and will be removed in NetBox v5.0. Unauthenticated access to the application will no longer be supported once this configuration parameter is removed.
+
 Default: `True`
 
 When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
@@ -172,9 +175,9 @@ When enabled, only authenticated users are permitted to access any part of NetBo
 
 ## 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).
 
 ---
 

+ 7 - 0
docs/configuration/system.md

@@ -105,6 +105,13 @@ A list of IP addresses recognized as internal to the system, used to control the
 example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
 addresses (and [`DEBUG`](./development.md#debug) is `True`).
 
+!!! info "New in NetBox v4.6"
+    Setting this parameter to an empty list will enable the toolbar for all requests provided debugging is enabled:
+
+    ```python
+    INTERNAL_IPS = []
+    ```
+
 ---
 
 ## ISOLATED_DEPLOYMENT

+ 1 - 0
docs/customization/custom-fields.md

@@ -63,6 +63,7 @@ NetBox supports limited custom validation for custom field values. Following are
 * Text: Regular expression (optional)
 * Integer: Minimum and/or maximum value (optional)
 * Selection: Must exactly match one of the prescribed choices
+* JSON: Must adhere to the defined validation schema (if any)
 
 ### Custom Selection Fields
 

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

@@ -115,6 +115,20 @@ commit_default = False
 
 By default, a script can be scheduled for execution at a later time. Setting `scheduling_enabled` to False disables this ability: Only immediate execution will be possible. (This also disables the ability to set a recurring execution interval.)
 
+### `notifications_default`
+
+By default, a notification is generated for the requesting user each time a script finishes running. This attribute sets the initial value for the notifications field when running a script. Valid values are `always` (default), `on_failure`, and `never`.
+
+```python
+notifications_default = 'on_failure'
+```
+
+| Value | Behavior |
+|-------|----------|
+| `always` | Notify on every completion (default) |
+| `on_failure` | Notify only when the job fails or errors |
+| `never` | Never send a notification |
+
 ### `job_timeout`
 
 Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used.

+ 3 - 0
docs/development/application-registry.md

@@ -32,6 +32,9 @@ Core model features are listed in the [features matrix](./models.md#features-mat
 
 ### `models`
 
+!!! warning "Deprecated"
+    Usage of this key has been deprecated and will be removed in NetBox v4.7. Use `ObjectType.objects.public()` to find registered models.
+
 This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
 
 ### `plugins`

+ 1 - 1
docs/development/getting-started.md

@@ -97,7 +97,7 @@ NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate co
 * Run the `ruff` Python linter
 * Run Django's internal system check
 * Check for missing database migrations
-* Validate any changes to the documentation with `mkdocs`
+* Validate any changes to the documentation with `zensical`
 * Validate Typescript & Sass styling with `yarn`
 * Ensure that any modified static front end assets have been recompiled
 

+ 3 - 0
docs/development/models.md

@@ -45,6 +45,7 @@ These are considered the "core" application models which are used to model netwo
 * [core.DataSource](../models/core/datasource.md)
 * [core.Job](../models/core/job.md)
 * [dcim.Cable](../models/dcim/cable.md)
+* [dcim.CableBundle](../models/dcim/cablebundle.md)
 * [dcim.Device](../models/dcim/device.md)
 * [dcim.DeviceType](../models/dcim/devicetype.md)
 * [dcim.Module](../models/dcim/module.md)
@@ -73,6 +74,7 @@ These are considered the "core" application models which are used to model netwo
 * [tenancy.Tenant](../models/tenancy/tenant.md)
 * [virtualization.Cluster](../models/virtualization/cluster.md)
 * [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
+* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md)
 * [vpn.IKEPolicy](../models/vpn/ikepolicy.md)
 * [vpn.IKEProposal](../models/vpn/ikeproposal.md)
 * [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md)
@@ -92,6 +94,7 @@ Organization models are used to organize and classify primary models.
 * [dcim.DeviceRole](../models/dcim/devicerole.md)
 * [dcim.Manufacturer](../models/dcim/manufacturer.md)
 * [dcim.Platform](../models/dcim/platform.md)
+* [dcim.RackGroup](../models/dcim/rackgroup.md)
 * [dcim.RackRole](../models/dcim/rackrole.md)
 * [ipam.ASNRange](../models/ipam/asnrange.md)
 * [ipam.RIR](../models/ipam/rir.md)

+ 1 - 1
docs/development/release-checklist.md

@@ -47,7 +47,7 @@ If a new Django release is adopted or other major dependencies (Python, PostgreS
 Start the documentation server and navigate to the current version of the installation docs:
 
 ```no-highlight
-mkdocs serve
+zensical serve
 ```
 
 Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.

+ 0 - 4
docs/extra.css

@@ -5,10 +5,6 @@ img {
     margin-right: auto;
 }
 
-.md-content img {
-    background-color: rgba(255, 255, 255, 0.64);
-}
-
 /* Tables */
 table {
     margin-bottom: 24px;

+ 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
     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
 flowchart TD
-    Manufacturer -.-> Platform & DeviceType & ModuleType
+    Manufacturer -.-> Platform
     Manufacturer --> DeviceType & ModuleType
+    ModuleTypeProfile -.-> ModuleType
     DeviceRole & Platform & DeviceType --> Device
     Device & ModuleType ---> Module
     Device & Module --> Interface & ConsolePort & PowerPort & ...
+    Interface --> MACAddress
 
 click Device "../../models/dcim/device/"
 click DeviceRole "../../models/dcim/devicerole/"
 click DeviceType "../../models/dcim/devicetype/"
+click Interface "../../models/dcim/interface/"
+click MACAddress "../../models/dcim/macaddress/"
 click Manufacturer "../../models/dcim/manufacturer/"
 click Module "../../models/dcim/module/"
 click ModuleType "../../models/dcim/moduletype/"
+click ModuleTypeProfile "../../models/dcim/moduletypeprofile/"
 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.
 
-## 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.
 
+### 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"
     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`.
 
+## 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
 
 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
     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
     Site --> Rack
     RackRole --> Rack
+    RackGroup --> Rack
 
 click Device "../../models/dcim/device/"
 click Location "../../models/dcim/location/"
 click Rack "../../models/dcim/rack/"
+click RackGroup "../../models/dcim/rackgroup/"
 click RackRole "../../models/dcim/rackrole/"
 click Region "../../models/dcim/region/"
 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.
 
+## 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
 
 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"
     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.

+ 27 - 9
docs/features/virtualization.md

@@ -1,26 +1,44 @@
 # Virtualization
 
-Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
+Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks.
 
 ```mermaid
 flowchart TD
     ClusterGroup & ClusterType --> Cluster
+    VirtualMachineType --> VirtualMachine
+    Device --> VirtualMachine
     Cluster --> VirtualMachine
     Platform --> VirtualMachine
     VirtualMachine --> VMInterface
 
-click Cluster "../../models/virtualization/cluster/"
-click ClusterGroup "../../models/virtualization/clustergroup/"
-click ClusterType "../../models/virtualization/clustertype/"
-click Platform "../../models/dcim/platform/"
-click VirtualMachine "../../models/virtualization/virtualmachine/"
-click VMInterface "../../models/virtualization/vminterface/"
+    click Cluster "../../models/virtualization/cluster/"
+    click ClusterGroup "../../models/virtualization/clustergroup/"
+    click ClusterType "../../models/virtualization/clustertype/"
+    click VirtualMachineType "../../models/virtualization/virtualmachinetype/"
+    click Device "../../models/dcim/device/"
+    click Platform "../../models/dcim/platform/"
+    click VirtualMachine "../../models/virtualization/virtualmachine/"
+    click VMInterface "../../models/virtualization/vminterface/"
 ```
 
 ## Clusters
 
-A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
+A cluster is one or more physical host devices on which virtual machines can run.
+
+Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional.
+
+## Virtual Machine Types
+
+A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation.
 
 ## Virtual Machines
 
-A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may also define its compute, memory, and storage resources as well.
+A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes.
+
+For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time.
+
+A VM can be placed in one of three ways:
+
+- Assigned to a site alone for logical grouping.
+- Assigned to a cluster and optionally pinned to a specific host device within that cluster.
+- Assigned directly to a standalone device that does not belong to any cluster.

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

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

+ 3 - 1
docs/installation/index.md

@@ -28,9 +28,11 @@ The following sections detail how to set up a new instance of NetBox:
 | Dependency | Supported Versions |
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+                |
+| PostgreSQL | 14+ [^1]           |
 | Redis      | 4.0+               |
 
+[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
+
 Below is a simplified overview of the NetBox application stack for reference:
 
 ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png)

+ 4 - 1
docs/installation/upgrading.md

@@ -20,13 +20,16 @@ NetBox requires the following dependencies:
 | Dependency | Supported Versions |
 |------------|--------------------|
 | Python     | 3.12, 3.13, 3.14   |
-| PostgreSQL | 14+                |
+| PostgreSQL | 14+ [^1]           |
 | Redis      | 4.0+               |
 
+[^1]: Support for PostgreSQL 14 is deprecated and will be removed in NetBox v4.7. PostgreSQL 15 or later will be required.
+
 ### Version History
 
 | 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.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) |

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

@@ -341,7 +341,7 @@ When retrieving devices and virtual machines via the REST API, each will include
 
 ## Pagination
 
-API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:
+API responses which contain a list of many objects will be paginated for efficiency. NetBox employs offset-based pagination by default, which forms a page by skipping the number of objects indicated by the `offset` URL parameter. The root JSON object returned by a list endpoint contains the following attributes:
 
 * `count`: The total number of all objects matching the query
 * `next`: A hyperlink to the next page of results (if applicable)
@@ -398,6 +398,49 @@ The maximum number of objects that can be returned is limited by the [`MAX_PAGE_
 !!! warning
     Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
 
+### Cursor-Based Pagination
+
+For large datasets, offset-based pagination can become inefficient because the database must scan all rows up to the offset. As an alternative, cursor-based pagination uses the `start` query parameter to filter results by primary key (PK), enabling efficient keyset pagination.
+
+To use cursor-based pagination, pass `start` (the minimum PK value) and `limit` (the page size):
+
+```
+http://netbox/api/dcim/devices/?start=0&limit=100
+```
+
+This returns objects with an `id` greater than or equal to zero, ordered by PK, limited to 100 results. Below is an example showing an arbitrary `start` value.
+
+```json
+{
+    "count": null,
+    "next": "http://netbox/api/dcim/devices/?start=356&limit=100",
+    "previous": null,
+    "results": [
+        {
+            "id": 109,
+            "name": "dist-router07",
+            ...
+        },
+        ...
+        {
+            "id": 356,
+            "name": "acc-switch492",
+            ...
+        }
+    ]
+}
+```
+
+To iterate through all results, use the `id` of the last object in each response plus one as the `start` value for the next request. Continue until `next` is null.
+
+!!! info
+    Some important differences from offset-based pagination:
+
+    * `start` and `offset` are **mutually exclusive**; specifying both will result in a 400 error.
+    * Results are always ordered by primary key when using `start`. This is required to ensure deterministic behavior.
+    * `count` is always `null` in cursor mode, as counting all matching rows would partially negate its performance benefit.
+    * `previous` is always `null`: cursor-based pagination supports only forward navigation.
+
 ## Interacting with Objects
 
 ### Retrieving Multiple Objects
@@ -620,6 +663,51 @@ Note that there is no requirement for the attributes to be identical among objec
 !!! 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.
 
+### 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
 
 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.
@@ -828,3 +916,11 @@ GET /api/dcim/sites/?created_by_request=e39c84bc-f169-4d5f-bc1c-94487a1b18b5
 
 !!! note
     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.

+ 18 - 2
docs/integrations/webhooks.md

@@ -26,10 +26,20 @@ The following data is available as context for Jinja2 templates:
 * `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
 * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
 * `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
-* `username` - The name of the user account associated with the change.
-* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
+* `request` - Data about the triggering request (if available).
+    * `request.id` - The UUID associated with the request
+    * `request.method` - The HTTP method (e.g. `GET` or `POST`)
+    * `request.path` - The URL path (ex: `/dcim/sites/123/edit/`)
+    * `request.user` - The name of the authenticated user who made the request (if available)
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
 * `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
+* ⚠️ `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
+* ⚠️ `username` - The name of the user account associated with the change.
+
+!!! warning "Deprecation of legacy keys"
+    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
+
+    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
 
 ### Default Request Body
 
@@ -56,6 +66,12 @@ If no body template is specified, the request body will be populated with a JSON
         "region": null,
         ...
     },
+    "request": {
+        "id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
+        "method": "POST",
+        "path": "/dcim/sites/add/",
+        "user": "jstretch"
+    },
     "snapshots": {
         "prechange": null,
         "postchange": {

+ 3 - 1
docs/introduction.md

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

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

+ 15 - 0
docs/models/dcim/cablebundle.md

@@ -0,0 +1,15 @@
+# Cable Bundles
+
+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, allowing the bundle to be reused for replacement cables.
+
+## Fields
+
+### Name
+
+A unique name for the cable bundle.
+
+### Description
+
+An optional short description of the bundle's purpose or contents.

+ 5 - 0
docs/models/dcim/devicebay.md

@@ -23,3 +23,8 @@ The device bay's name. Must be unique to the parent device.
 ### Label
 
 An alternative physical label identifying the device bay.
+
+### Enabled
+
+Whether this device bay is enabled. Disabled device bays are not available for installation.
+

+ 12 - 0
docs/models/dcim/devicetype.md

@@ -7,6 +7,18 @@ Device types are instantiated as devices installed within sites and/or equipment
 !!! note
     This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device.
 
+## Automatic Component Renaming
+
+When adding component templates to a device type, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/0/0` installed on a Virtual Chassis
+member with position `2` will be rendered as `Gi2/0/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
 ## Fields
 
 ### Manufacturer

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

@@ -1,11 +1,25 @@
 # 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
 
 ### 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.

+ 6 - 1
docs/models/dcim/modulebay.md

@@ -1,6 +1,6 @@
 # Module Bays
 
-Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device.
+Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules, in turn, hold additional components that become available to the parent device.
 
 !!! note
     If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead.
@@ -29,3 +29,8 @@ An alternative physical label identifying the module bay.
 ### Position
 
 The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch.
+
+### Enabled
+
+Whether this module bay is enabled. Disabled module bays are not available for installation.
+

+ 30 - 0
docs/models/dcim/moduletype.md

@@ -20,8 +20,38 @@ When adding component templates to a module type, the string `{module}` can be u
 
 For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`.
 
+Similarly, the string `{vc_position}` can be used in component template names to reference the
+`vc_position` field of the device being provisioned, when that device is a member of a Virtual Chassis.
+
+For example, an interface template named `Gi{vc_position}/{module}/0` installed on a Virtual Chassis
+member with position `2` and module bay position `3` will be rendered as `Gi2/3/0`.
+
+If the device is not a member of a Virtual Chassis, `{vc_position}` defaults to `0`. A custom
+fallback value can be specified using the syntax `{vc_position:X}`, where `X` is the desired default.
+For example, `{vc_position:1}` will render as `1` when no Virtual Chassis position is set.
+
 Automatic renaming is supported for all modular component types (those listed above).
 
+### Position Inheritance for Nested Modules
+
+When using nested module bays (modules installed inside other modules), the `{module}` placeholder
+can also be used in the **position** field of module bay templates to inherit the parent bay's
+position. This allows a single module type to produce correctly named components at any nesting
+depth, with a user-controlled separator.
+
+For example, a line card module type might define sub-bay positions as `{module}/1`, `{module}/2`,
+etc. When the line card is installed in a device bay with position `3`, these sub-bay positions
+resolve to `3/1`, `3/2`, etc. An SFP module type with interface template `SFP {module}` installed
+in sub-bay `3/2` then produces interface `SFP 3/2`.
+
+The separator between levels is defined by the user in the position field template itself. Using
+`{module}-1` produces positions like `3-1`, while `{module}.1` produces `3.1`. This provides
+full flexibility without requiring a global separator configuration.
+
+!!! note
+    If the position field does not contain `{module}`, no inheritance occurs and behavior is
+    unchanged from previous versions.
+
 ## Fields
 
 ### Manufacturer

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

@@ -1,8 +1,8 @@
 # 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
 {
@@ -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
 
+### Name
+
+A unique name for the profile (for example, `Power Supply` or `Disk`).
+
+### Description
+
+An optional description of the profile.
+
 ### 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.

+ 5 - 1
docs/models/dcim/rack.md

@@ -1,6 +1,6 @@
 # Racks
 
-The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique.
+The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles or by [rack groups](./rackgroup.md). The name and facility ID of each rack within a location must be unique.
 
 Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
 
@@ -16,6 +16,10 @@ The [site](./site.md) to which the rack is assigned.
 
 The [location](./location.md) within a site where the rack has been installed (optional).
 
+### Rack Group
+
+The [group](./rackgroup.md) used to organize racks by physical placement (optional).
+
 ### Name
 
 The rack's name or identifier. Must be unique to the rack's location, if assigned.

+ 15 - 0
docs/models/dcim/rackgroup.md

@@ -0,0 +1,15 @@
+# Rack Groups
+
+Racks can optionally be assigned to rack groups to reflect their physical placement. Rack groups provide a secondary means of categorization alongside [locations](./location.md), which is particularly useful for datacenter operators who need to group racks by row, aisle, or similar physical arrangement while keeping them assigned to the same location, such as a cage or room. Rack groups are flat and do not form a hierarchy.
+
+Rack groups can also be used to scope [VLAN groups](../ipam/vlangroup.md), which can help model L2 domains spanning rows or pairs of racks.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)

+ 4 - 0
docs/models/dcim/rackreservation.md

@@ -12,6 +12,10 @@ The [rack](./rack.md) being reserved.
 
 The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7.
 
+### Total U's
+
+A calculated (read-only) field that reflects the total number of units in the reservation. Can be filtered upon using `unit_count_min` and `unit_count_max` parameters in the UI or API.
+
 ### Status
 
 The current status of the reservation. (This is for documentation only: The status of a reservation has no impact on the installation of devices within a reserved rack unit.)

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

@@ -1,6 +1,6 @@
 # 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:
 
@@ -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
 
 ### Name
 
-A unique human-friendly name.
+A unique, human-friendly name for the profile.
+
+### Description
+
+An optional description of the profile's purpose.
 
 ### 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.

+ 6 - 0
docs/models/extras/customfield.md

@@ -103,6 +103,8 @@ The default value to populate for the custom field when creating new objects (op
 
 For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
 
+Choice sets may optionally define colors for individual values. Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Cloneable
 
 If enabled, values from this field will be automatically pre-populated when cloning existing objects.
@@ -118,3 +120,7 @@ For numeric custom fields only. The maximum valid value (optional).
 ### Validation Regex
 
 For string-based custom fields only. A regular expression used to validate the field's value (optional).
+
+### Validation Schema
+
+For JSON custom fields, users have the option of defining a [validation schema](https://json-schema.org). Any value applied to this custom field on a model will be validated against the provided schema, if any.

+ 22 - 0
docs/models/extras/customfieldchoiceset.md

@@ -22,6 +22,28 @@ The set of pre-defined choices to include. Available sets are listed below. This
 
 A set of custom choices that will be appended to the base choice set (if any).
 
+### Choice Colors
+
+Optional color bindings for individual choice values. Each color is bound to a choice by its value rather than its label.
+
+When editing a choice set in the UI, enter one mapping per line using the format `value:color`. Supported colors are:
+
+* `blue`
+* `indigo`
+* `purple`
+* `pink`
+* `red`
+* `orange`
+* `yellow`
+* `green`
+* `teal`
+* `cyan`
+* `gray`
+* `black`
+* `white`
+
+Colored choices are rendered as badges on object detail pages for selection and multiple-selection custom fields.
+
 ### Order Alphabetically
 
 If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

+ 4 - 1
docs/models/extras/webhook.md

@@ -81,10 +81,13 @@ The following context variables are available to the text and link templates.
 
 | Variable      | Description                                          |
 |---------------|------------------------------------------------------|
-| `event`       | The event type (`created`, `updated`, or `deleted`)  |
+| `event`       | The event type (`create`, `update`, or `delete`)     |
 | `timestamp`   | The time at which the event occurred                 |
 | `object_type` | The type of object impacted (`app_label.model_name`) |
 | `username`    | The name of the user associated with the change      |
 | `request_id`  | The unique request ID                                |
 | `data`        | A complete serialized representation of the object   |
 | `snapshots`   | Pre- and post-change snapshots of the object         |
+
+!!! warning "Deprecation of legacy fields"
+    The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user` and `request.id` from the `request` object included in the callback context instead. (Note that `request` is populated in the context only when the webhook is associated with a triggering request.)

+ 4 - 0
docs/models/ipam/asn.md

@@ -14,6 +14,10 @@ The 16- or 32-bit AS number.
 
 The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN.
 
+### Role
+
+The user-defined functional [role](./role.md) assigned to this ASN.
+
 ### Sites
 
 The [site(s)](../dcim/site.md) to which this ASN is assigned.

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

@@ -18,6 +18,10 @@ 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.
 
+### 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.
+
 ### Scope
 
 The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies.

+ 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
 
-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
 
 ### 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 and are retained for backward compatibility; their use is discouraged. See the [REST API authentication](../../integrations/rest-api.md#authentication) documentation for the request header formats used by each version.
+
+## Fields
+
+### Version
+
+Indicates whether this is a v1 (legacy) or v2 token. v2 is the default and is strongly preferred.
+
+### 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.

+ 29 - 13
docs/models/virtualization/virtualmachine.md

@@ -1,18 +1,27 @@
 # Virtual Machines
 
-A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster.
+A virtual machine (VM) represents a virtual compute instance hosted within a cluster or directly on a device. Each VM must be assigned to at least one of: a [site](../dcim/site.md), a [cluster](./cluster.md), or a [device](../dcim/device.md).
 
-Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
+Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the VM can be designated, for both IPv4 and IPv6.
 
 ## Fields
 
 ### Name
 
-The virtual machine's configured name. Must be unique to the assigned cluster and tenant.
+The virtual machine's configured name. Must be unique within its scoping context:
+
+- If assigned to a **cluster**: unique within the cluster and tenant.
+- If assigned to a **device** (no cluster): unique within the device and tenant.
+
+### Type
+
+The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created.
+
+Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
 
 ### Role
 
-The functional [role](../dcim/devicerole.md) assigned to the VM.
+The functional role assigned to the VM.
 
 ### Status
 
@@ -21,24 +30,28 @@ The VM's operational status.
 !!! tip
     Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
-### Start on boot
+### Start on Boot
 
 The start on boot setting from the hypervisor.
 
 !!! tip
     Additional statuses may be defined by setting `VirtualMachine.start_on_boot` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
-### Site & Cluster
+### Site / Cluster / Device
 
-The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned.
+The location or host for this VM. At least one must be specified:
 
-### Device
+- **Site only**: The VM exists at a site but is not assigned to a specific cluster or device.
+- **Cluster only**: The VM belongs to a virtualization cluster. The site is automatically inferred from the cluster's scope.
+- **Device only**: The VM runs directly on a physical host device without a cluster (e.g. containers). The site is automatically inferred from the device's site.
+- **Cluster + Device**: The VM belongs to a cluster and is pinned to a specific host device within that cluster. The device must be a registered host of the assigned cluster.
 
-The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides.
+!!! info "New in NetBox v4.6"
+    Virtual machines can now be assigned directly to a device without requiring a cluster. This is particularly useful for modeling VMs running on standalone hosts outside of a cluster.
 
 ### Platform
 
-A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system.
+A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
 
 ### Primary IPv4 & IPv6 Addresses
 
@@ -49,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f
 
 ### vCPUs
 
-The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
+The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified.
 
 ### Memory
 
-The amount of running memory provisioned, in megabytes.
+The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified.
 
 ### Disk
 
@@ -64,4 +77,7 @@ The amount of disk storage provisioned, in megabytes.
 
 ### Serial Number
 
-Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.
+Optional serial number assigned to this virtual machine.
+
+!!! info
+    Unlike devices, uniqueness is not enforced for virtual machine serial numbers.

+ 27 - 0
docs/models/virtualization/virtualmachinetype.md

@@ -0,0 +1,27 @@
+# Virtual Machine Types
+
+A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md).
+
+A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines.
+
+## Fields
+
+### Name
+
+A unique human-friendly name.
+
+### Slug
+
+A unique URL-friendly identifier. (This value can be used for filtering.)
+
+### Default Platform
+
+If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided.
+
+### Default vCPUs
+
+The default number of vCPUs to assign when creating a virtual machine from this type.
+
+### Default Memory
+
+The default amount of memory, in megabytes, to assign when creating a virtual machine from this type.

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

+ 71 - 0
docs/plugins/development/permissions.md

@@ -0,0 +1,71 @@
+# Custom Model Actions
+
+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 fetches data from an external source, or a `bypass` action that allows users to skip certain restrictions.
+
+## Registering Model Actions
+
+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 title="models.py"
+from netbox.models import NetBoxModel
+
+class WidgetSync(NetBoxModel):
+    # ... fields ...
+
+    class Meta:
+        permissions = [
+            ('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. 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())
+```

+ 69 - 28
docs/plugins/development/ui-components.md

@@ -1,12 +1,9 @@
 # UI Components
 
-!!! note "New in NetBox v4.5"
-    All UI components described here were introduced in NetBox v4.5. Be sure to set the minimum NetBox version to 4.5.0 for your plugin before incorporating any of these resources.
+!!! note "New in NetBox v4.6"
+    All UI components described here were introduced in NetBox v4.6. Be sure to set the minimum NetBox version to 4.6.0 for your plugin before incorporating any of these resources.
 
-!!! danger "Beta Feature"
-    UI components are considered a beta feature, and are still under active development. Please be aware that the API for resources on this page is subject to change in future releases.
-
-To simply the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
+To simplify the process of designing your plugin's user interface, and to encourage a consistent look and feel throughout the entire application, NetBox provides a set of components that enable programmatic UI design. These make it possible to declare complex page layouts with little or no custom HTML.
 
 ## Page Layout
 
@@ -75,9 +72,12 @@ class RecentChangesPanel(Panel):
             **super().get_context(context),
             'changes': get_changes()[:10],
         }
+
+    def should_render(self, context):
+        return len(context['changes']) > 0
 ```
 
-NetBox also includes a set of panels suite for specific uses, such as display object details or embedding a table of related objects. These are listed below.
+NetBox also includes a set of panels suited for specific uses, such as displaying object details or embedding a table of related objects. These are listed below.
 
 ::: netbox.ui.panels.Panel
 
@@ -85,26 +85,6 @@ NetBox also includes a set of panels suite for specific uses, such as display ob
 
 ::: netbox.ui.panels.ObjectAttributesPanel
 
-#### Object Attributes
-
-The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
-
-| Class                                | Description                                      |
-|--------------------------------------|--------------------------------------------------|
-| `netbox.ui.attrs.AddressAttr`        | A physical or mailing address.                   |
-| `netbox.ui.attrs.BooleanAttr`        | A boolean value                                  |
-| `netbox.ui.attrs.ColorAttr`          | A color expressed in RGB                         |
-| `netbox.ui.attrs.ChoiceAttr`         | A selection from a set of choices                |
-| `netbox.ui.attrs.GPSCoordinatesAttr` | GPS coordinates (latitude and longitude)         |
-| `netbox.ui.attrs.ImageAttr`          | An attached image (displays the image)           |
-| `netbox.ui.attrs.NestedObjectAttr`   | A related nested object                          |
-| `netbox.ui.attrs.NumericAttr`        | An integer or float value                        |
-| `netbox.ui.attrs.RelatedObjectAttr`  | A related object                                 |
-| `netbox.ui.attrs.TemplatedAttr`      | Renders an attribute using a custom template     |
-| `netbox.ui.attrs.TextAttr`           | A string (text) value                            |
-| `netbox.ui.attrs.TimezoneAttr`       | A timezone with annotated offset                 |
-| `netbox.ui.attrs.UtilizationAttr`    | A numeric value expressed as a utilization graph |
-
 ::: netbox.ui.panels.OrganizationalObjectPanel
 
 ::: netbox.ui.panels.NestedGroupObjectPanel
@@ -119,9 +99,13 @@ The following classes are available to represent object attributes within an Obj
 
 ::: netbox.ui.panels.TemplatePanel
 
+::: netbox.ui.panels.TextCodePanel
+
+::: netbox.ui.panels.ContextTablePanel
+
 ::: netbox.ui.panels.PluginContentPanel
 
-## Panel Actions
+### Panel Actions
 
 Each panel may have actions associated with it. These render as links or buttons within the panel header, opposite the panel's title. For example, a common use case is to include an "Add" action on a panel which displays a list of objects. Below is an example of this.
 
@@ -146,3 +130,60 @@ panels.ObjectsTablePanel(
 ::: netbox.ui.actions.AddObject
 
 ::: netbox.ui.actions.CopyContent
+
+## Object Attributes
+
+The following classes are available to represent object attributes within an ObjectAttributesPanel. Additionally, plugins can subclass `netbox.ui.attrs.ObjectAttribute` to create custom classes.
+
+| Class                                    | Description                                      |
+|------------------------------------------|--------------------------------------------------|
+| `netbox.ui.attrs.AddressAttr`            | A physical or mailing address.                   |
+| `netbox.ui.attrs.BooleanAttr`            | A boolean value                                  |
+| `netbox.ui.attrs.ChoiceAttr`             | A selection from a set of choices                |
+| `netbox.ui.attrs.ColorAttr`              | A color expressed in RGB                         |
+| `netbox.ui.attrs.DateTimeAttr`           | A date or datetime value                         |
+| `netbox.ui.attrs.GenericForeignKeyAttr`  | A related object via a generic foreign key       |
+| `netbox.ui.attrs.GPSCoordinatesAttr`     | GPS coordinates (latitude and longitude)         |
+| `netbox.ui.attrs.ImageAttr`              | An attached image (displays the image)           |
+| `netbox.ui.attrs.NestedObjectAttr`       | A related nested object (includes ancestors)     |
+| `netbox.ui.attrs.NumericAttr`            | An integer or float value                        |
+| `netbox.ui.attrs.RelatedObjectAttr`      | A related object                                 |
+| `netbox.ui.attrs.RelatedObjectListAttr`  | A list of related objects                        |
+| `netbox.ui.attrs.TemplatedAttr`          | Renders an attribute using a custom template     |
+| `netbox.ui.attrs.TextAttr`               | A string (text) value                            |
+| `netbox.ui.attrs.TimezoneAttr`           | A timezone with annotated offset                 |
+| `netbox.ui.attrs.UtilizationAttr`        | A numeric value expressed as a utilization graph |
+
+::: netbox.ui.attrs.ObjectAttribute
+
+::: netbox.ui.attrs.AddressAttr
+
+::: netbox.ui.attrs.BooleanAttr
+
+::: netbox.ui.attrs.ChoiceAttr
+
+::: netbox.ui.attrs.ColorAttr
+
+::: netbox.ui.attrs.DateTimeAttr
+
+::: netbox.ui.attrs.GenericForeignKeyAttr
+
+::: netbox.ui.attrs.GPSCoordinatesAttr
+
+::: netbox.ui.attrs.ImageAttr
+
+::: netbox.ui.attrs.NestedObjectAttr
+
+::: netbox.ui.attrs.NumericAttr
+
+::: netbox.ui.attrs.RelatedObjectAttr
+
+::: netbox.ui.attrs.RelatedObjectListAttr
+
+::: netbox.ui.attrs.TemplatedAttr
+
+::: netbox.ui.attrs.TextAttr
+
+::: netbox.ui.attrs.TimezoneAttr
+
+::: netbox.ui.attrs.UtilizationAttr

+ 6 - 0
docs/plugins/development/webhooks.md

@@ -36,6 +36,7 @@ The resulting webhook payload will look like the following:
         "url": "/api/dcim/sites/2/",
         ...
     },
+    "request": {...},
     "snapshots": {...},
     "context": {
         "foo": 123
@@ -43,6 +44,11 @@ The resulting webhook payload will look like the following:
 }
 ```
 
+!!! warning "Deprecation of legacy keys"
+    The `request_id` and `username` keys in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
+
+    Use `request.user` and `request.id` from the `request` object included in the callback context instead.
+
 !!! note "Consider namespacing webhook data"
     The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such:
     

+ 8 - 0
docs/release-notes/index.md

@@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea
 
 This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
 
+#### [Version 4.6](./version-4.6.md) (May 2026)
+
+* Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
+* Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
+* Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
+* ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
+* Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
+
 #### [Version 4.5](./version-4.5.md) (January 2026)
 
 * Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))

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

@@ -0,0 +1,133 @@
+# NetBox v4.6
+
+## v4.6.0 (2026-05-05)
+
+### New Features
+
+#### Virtual Machine Types ([#5795](https://github.com/netbox-community/netbox/issues/5795))
+
+A new VirtualMachineType model has been introduced to enable categorization of virtual machines by instance type, analogous to how DeviceType categorizes physical hardware. VM types can be defined once and reused across many virtual machines.
+
+#### Cable Bundles ([#20151](https://github.com/netbox-community/netbox/issues/20151))
+
+A new CableBundle model allows individual cables to be grouped together to represent physical cable runs that are managed as a unit; e.g. a bundle of 48 CAT6 cables between two patch panels. (Please note that this feature is _not_ suitable for modeling individual fiber strands within a single cable.)
+
+#### Rack Groups ([#20961](https://github.com/netbox-community/netbox/issues/20961))
+
+A flat RackGroup model has been reintroduced to provide a lightweight secondary axis of rack organization (e.g. by row or aisle) that is independent of the location hierarchy. Racks carry an optional foreign key to a RackGroup, and RackGroup can also serve as a scope for VLANGroup assignments.
+
+#### ETag Support for REST API ([#21356](https://github.com/netbox-community/netbox/issues/21356))
+
+The REST API now returns an `ETag` header on responses for individual objects, derived from the object's last-updated timestamp. Clients can supply an `If-Match` header on PUT/PATCH requests to guard against conflicting concurrent updates; if the object has been modified since the ETag was issued, the server returns a 412 (Precondition Failed) response.
+
+#### Cursor-based Pagination for REST API ([#21363](https://github.com/netbox-community/netbox/issues/21363))
+
+A new `start` query parameter has been introduced as an efficient alternative to the existing `offset` parameter for paginating large result sets. Rather than scanning the table up to a relative offset, the `start` parameter filters for objects with a primary key equal to or greater than the given value, enabling constant-time pagination regardless of result set size.
+
+### Enhancements
+
+* [#12024](https://github.com/netbox-community/netbox/issues/12024) - Permit virtual machines to be assigned to devices without a cluster
+* [#14329](https://github.com/netbox-community/netbox/issues/14329) - Improve diff highlighting for custom field data in change logs
+* [#15513](https://github.com/netbox-community/netbox/issues/15513) - Add bulk creation support for IP prefixes
+* [#17654](https://github.com/netbox-community/netbox/issues/17654) - Support role assignment for ASNs
+* [#19025](https://github.com/netbox-community/netbox/issues/19025) - Support optional schema validation for JSON custom fields
+* [#19034](https://github.com/netbox-community/netbox/issues/19034) - Annotate total reserved unit count on rack reservations
+* [#19138](https://github.com/netbox-community/netbox/issues/19138) - Include NAT addresses for primary & out-of-band IP addresses in REST API
+* [#19648](https://github.com/netbox-community/netbox/issues/19648) - Add a color custom field type
+* [#19796](https://github.com/netbox-community/netbox/issues/19796) - Support `{module}` position inheritance for nested module bays
+* [#19953](https://github.com/netbox-community/netbox/issues/19953) - Enable debugging support for ConfigTemplate rendering
+* [#20123](https://github.com/netbox-community/netbox/issues/20123) - Introduce options to control adoption/replication of device components via REST API (replicates UI behavior)
+* [#20152](https://github.com/netbox-community/netbox/issues/20152) - Support for marking module and device bays as disabled
+* [#20162](https://github.com/netbox-community/netbox/issues/20162) - Provide an option to execute as a background job when adding components to devices in bulk
+* [#20163](https://github.com/netbox-community/netbox/issues/20163) - Add changelog message support for bulk device component creation
+* [#20698](https://github.com/netbox-community/netbox/issues/20698) - Add read-only `total_vlan_ids` attribute on VLAN group representation in REST & GraphQL APIs
+* [#20916](https://github.com/netbox-community/netbox/issues/20916) - Include stack trace for unhandled exceptions in job logs
+* [#21157](https://github.com/netbox-community/netbox/issues/21157) - Include all public model classes in export template context
+* [#21409](https://github.com/netbox-community/netbox/issues/21409) - Introduce `CHANGELOG_RETAIN_CREATE_LAST_UPDATE` configuration parameter to retain creation & most recent update record in change log for each object
+* [#21575](https://github.com/netbox-community/netbox/issues/21575) - Introduce `{vc_position}` template variable for device component template name/label
+* [#21662](https://github.com/netbox-community/netbox/issues/21662) - Increase `rf_channel_frequency` precision to 3 decimal places
+* [#21702](https://github.com/netbox-community/netbox/issues/21702) - Include a serialized representation of the HTTP request in each webhook
+* [#21720](https://github.com/netbox-community/netbox/issues/21720) - Align HTTP basic auth regex of `EnhancedURLValidator` with Django's `URLValidator`
+* [#21751](https://github.com/netbox-community/netbox/issues/21751) - Disable notifications for scripts running in the background
+* [#21770](https://github.com/netbox-community/netbox/issues/21770) - Enable specifying columns to include/exclude on embedded tables
+* [#21771](https://github.com/netbox-community/netbox/issues/21771) - Add support for partial tag assignment (`add_tags`) and removal (`remove_tags`) via REST API
+* [#21780](https://github.com/netbox-community/netbox/issues/21780) - Add changelog message support to bulk creation of IP addresses
+* [#21865](https://github.com/netbox-community/netbox/issues/21865) - Allow setting empty `INTERNAL_IPS` to enable debug toolbar for all clients
+* [#21924](https://github.com/netbox-community/netbox/issues/21924) - Improve styling and consistency of floating bulk action controls
+* [#22062](https://github.com/netbox-community/netbox/issues/22062) - Display API token ID & plaintext one time immediately upon creation
+
+### Performance Improvements
+
+* [#21455](https://github.com/netbox-community/netbox/issues/21455) - Ensure PostgreSQL indexes exist to support the default ordering of each model
+* [#21688](https://github.com/netbox-community/netbox/issues/21688) - Reduce per-position ORM lookups when tracing cable paths
+* [#21788](https://github.com/netbox-community/netbox/issues/21788) - Optimize bulk object export to avoid timeout errors on large querysets
+
+### Plugins
+
+* [#20924](https://github.com/netbox-community/netbox/issues/20924) - Introduce support for declarative layouts and reusable UI components
+* [#21357](https://github.com/netbox-community/netbox/issues/21357) - Provide an API for plugins to register custom model actions (for permission assignment)
+
+### Deprecations
+
+* [#21284](https://github.com/netbox-community/netbox/issues/21284) - Deprecate the `username` and `request_id` fields in event data
+* [#21304](https://github.com/netbox-community/netbox/issues/21304) - Deprecate the `housekeeping` management command
+* [#21331](https://github.com/netbox-community/netbox/issues/21331) - Deprecate NetBox's custom `querystring` template tag
+* [#21881](https://github.com/netbox-community/netbox/issues/21881) - Deprecate legacy Sentry configuration parameters
+* [#21884](https://github.com/netbox-community/netbox/issues/21884) - Deprecate the obsolete `DEFAULT_ACTION_PERMISSIONS` mapping
+* [#21887](https://github.com/netbox-community/netbox/issues/21887) - Deprecate support for legacy view actions
+* [#21890](https://github.com/netbox-community/netbox/issues/21890) - Deprecate `models` key in application registry
+* [#21936](https://github.com/netbox-community/netbox/issues/21936) - Deprecate the `LOGIN_REQUIRED` configuration parameter
+* [#22046](https://github.com/netbox-community/netbox/issues/22046) - Deprecate OptionalLimitOffsetPagination 
+* [#22047](https://github.com/netbox-community/netbox/issues/22047) - Deprecate ExpandableIPAddressField 
+* [#22048](https://github.com/netbox-community/netbox/issues/22048) - Deprecate the `expand_ipaddress_pattern()` utility function
+
+### Other Changes
+
+* [#20984](https://github.com/netbox-community/netbox/issues/20984) - Upgrade to Django 6.0
+* [#21635](https://github.com/netbox-community/netbox/issues/21635) - Migrate documentation site from mkdocs to Zensical
+
+### REST API Changes
+
+* New features:
+    * `ETag` response header and `If-Match` request header support for all individual object endpoints
+    * `start` query parameter for cursor-based pagination on all list endpoints
+    * `add_tags` and `remove_tags` write-only fields on all taggable model serializers
+* New endpoints:
+    * `GET/POST /api/dcim/cable-bundles/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/cable-bundles/<id>/`
+    * `GET/POST /api/dcim/rack-groups/`
+    * `GET/PUT/PATCH/DELETE /api/dcim/rack-groups/<id>/`
+    * `GET/POST /api/virtualization/virtual-machine-types/`
+    * `GET/PUT/PATCH/DELETE /api/virtualization/virtual-machine-types/<id>/`
+* `dcim.Cable`
+    * Add optional foreign key field `bundle`
+* `dcim.Device`
+    * The `primary_ip`, `primary_ip4`, `primary_ip6`, and `oob_ip` nested representations now include `nat_inside` and `nat_outside`
+* `dcim.DeviceBay`
+    * Add boolean field `enabled`
+    * Add read-only boolean field `_occupied`
+* `dcim.DeviceBayTemplate`
+    * Add boolean field `enabled`
+* `dcim.Module`
+    * Add write-only boolean fields `replicate_components` and `adopt_components`
+* `dcim.ModuleBay`
+    * Add boolean field `enabled`
+    * Add read-only boolean field `_occupied`
+* `dcim.ModuleBayTemplate`
+    * Add boolean field `enabled`
+* `dcim.Rack`
+    * Add optional foreign key field `group`
+* `dcim.RackReservation`
+    * Add read-only integer field `unit_count`
+* `extras.CustomField`
+    * Add JSON field `validation_schema`
+* `ipam.ASN`
+    * Add optional foreign key field `role`
+* `ipam.Role`
+    * Annotate count of assigned ASNs (`asn_count`)
+* `ipam.VLANGroup`
+    * Add read-only field `total_vlan_ids`
+* `virtualization.VirtualMachine`
+    * Add optional foreign key field `virtual_machine_type`
+    * The `primary_ip`, `primary_ip4`, and `primary_ip6` nested representations now include `nat_inside` and `nat_outside`
+    * The `cluster` field is now optional (nullable)

+ 12 - 0
mkdocs.yml

@@ -1,3 +1,4 @@
+# Note: NetBox has migrated from MkDocs to Zensical
 site_name: NetBox Documentation
 site_dir: netbox/project-static/docs
 site_url: https://docs.netbox.dev/
@@ -151,6 +152,7 @@ nav:
             - Filters & Filter Sets: 'plugins/development/filtersets.md'
             - Search: 'plugins/development/search.md'
             - Event Types: 'plugins/development/event-types.md'
+            - Permissions: 'plugins/development/permissions.md'
             - Data Backends: 'plugins/development/data-backends.md'
             - Webhooks: 'plugins/development/webhooks.md'
             - User Interface: 'plugins/development/user-interface.md'
@@ -187,8 +189,11 @@ nav:
             - DataFile: 'models/core/datafile.md'
             - DataSource: 'models/core/datasource.md'
             - Job: 'models/core/job.md'
+            - ObjectChange: 'models/core/objectchange.md'
+            - ObjectType: 'models/core/objecttype.md'
         - DCIM:
             - Cable: 'models/dcim/cable.md'
+            - CableBundle: 'models/dcim/cablebundle.md'
             - ConsolePort: 'models/dcim/consoleport.md'
             - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md'
             - ConsoleServerPort: 'models/dcim/consoleserverport.md'
@@ -221,6 +226,7 @@ nav:
             - PowerPort: 'models/dcim/powerport.md'
             - PowerPortTemplate: 'models/dcim/powerporttemplate.md'
             - Rack: 'models/dcim/rack.md'
+            - RackGroup: 'models/dcim/rackgroup.md'
             - RackReservation: 'models/dcim/rackreservation.md'
             - RackRole: 'models/dcim/rackrole.md'
             - RackType: 'models/dcim/racktype.md'
@@ -276,8 +282,12 @@ nav:
             - Tenant: 'models/tenancy/tenant.md'
             - TenantGroup: 'models/tenancy/tenantgroup.md'
         - Users:
+            - Group: 'models/users/group.md'
+            - ObjectPermission: 'models/users/objectpermission.md'
             - Owner: 'models/users/owner.md'
             - OwnerGroup: 'models/users/ownergroup.md'
+            - Token: 'models/users/token.md'
+            - User: 'models/users/user.md'
         - Virtualization:
             - Cluster: 'models/virtualization/cluster.md'
             - ClusterGroup: 'models/virtualization/clustergroup.md'
@@ -285,6 +295,7 @@ nav:
             - VMInterface: 'models/virtualization/vminterface.md'
             - VirtualDisk: 'models/virtualization/virtualdisk.md'
             - VirtualMachine: 'models/virtualization/virtualmachine.md'
+            - VirtualMachineType: 'models/virtualization/virtualmachinetype.md'
         - VPN:
             - IKEPolicy: 'models/vpn/ikepolicy.md'
             - IKEProposal: 'models/vpn/ikeproposal.md'
@@ -322,6 +333,7 @@ nav:
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'
+        - Version 4.6: 'release-notes/version-4.6.md'
         - Version 4.5: 'release-notes/version-4.5.md'
         - Version 4.4: 'release-notes/version-4.4.md'
         - Version 4.3: 'release-notes/version-4.3.md'

+ 10 - 0
netbox/account/views.py

@@ -356,9 +356,16 @@ class UserTokenView(LoginRequiredMixin, View):
     def get(self, request, pk):
         token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
 
+        # Pop a one-time plaintext value (set by UserTokenEditView.post_save when a token is first created) and
+        # assemble the full HTTP authorization string for display. The plaintext is never persisted; popping
+        # ensures the banner only renders once.
+        plaintext = request.session.pop(f'_token_plaintext_{token.pk}', None)
+        token_auth_string = f'{token.get_auth_header_prefix()}{plaintext}' if plaintext else None
+
         return render(request, 'account/token.html', {
             'object': token,
             'layout': self.layout,
+            'token_auth_string': token_auth_string,
         })
 
 
@@ -366,11 +373,14 @@ class UserTokenView(LoginRequiredMixin, View):
 class UserTokenEditView(generic.ObjectEditView):
     queryset = UserToken.objects.all()
     form = forms.UserTokenForm
+    template_name = 'account/usertoken_edit.html'
     default_return_url = 'account:usertoken_list'
 
     def alter_object(self, obj, request, url_args, url_kwargs):
         if not obj.pk:
             obj.user = request.user
+        # Attach the request so that UserTokenForm.save() can stash the newly-generated plaintext on the session.
+        obj._request = request
         return obj
 
 

+ 35 - 0
netbox/circuits/migrations/0057_default_ordering_indexes.py

@@ -0,0 +1,35 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0056_gfk_indexes'),
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0231_interface_rf_channel_frequency_precision'),
+        ('extras', '0136_customfield_validation_schema'),
+        ('tenancy', '0023_add_mptt_tree_indexes'),
+        ('users', '0015_owner'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='circuit',
+            index=models.Index(fields=['provider', 'provider_account', 'cid'], name='circuits_ci_provide_a0c42c_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='circuitgroupassignment',
+            index=models.Index(
+                fields=['group', 'member_type', 'member_id', 'priority', 'id'], name='circuits_ci_group_i_2f8327_idx'
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='virtualcircuit',
+            index=models.Index(
+                fields=['provider_network', 'provider_account', 'cid'], name='circuits_vi_provide_989efa_idx'
+            ),
+        ),
+        migrations.AddIndex(
+            model_name='virtualcircuittermination',
+            index=models.Index(fields=['virtual_circuit', 'role', 'id'], name='circuits_vi_virtual_4b5c0c_idx'),
+        ),
+    ]

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

@@ -144,6 +144,9 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
         )
+        indexes = (
+            models.Index(fields=('provider', 'provider_account', 'cid')),  # Default ordering
+        )
         verbose_name = _('circuit')
         verbose_name_plural = _('circuits')
 
@@ -221,6 +224,9 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin,
                 name='%(app_label)s_%(class)s_unique_member_group'
             ),
         )
+        indexes = (
+            models.Index(fields=('group', 'member_type', 'member_id', 'priority', 'id')),  # Default ordering
+        )
         verbose_name = _('Circuit group assignment')
         verbose_name_plural = _('Circuit group assignments')
 

+ 6 - 0
netbox/circuits/models/virtual_circuits.py

@@ -97,6 +97,9 @@ class VirtualCircuit(ContactsMixin, PrimaryModel):
                 name='%(app_label)s_%(class)s_unique_provideraccount_cid'
             ),
         )
+        indexes = (
+            models.Index(fields=('provider_network', 'provider_account', 'cid')),  # Default ordering
+        )
         verbose_name = _('virtual circuit')
         verbose_name_plural = _('virtual circuits')
 
@@ -150,6 +153,9 @@ class VirtualCircuitTermination(
 
     class Meta:
         ordering = ['virtual_circuit', 'role', 'pk']
+        indexes = (
+            models.Index(fields=('virtual_circuit', 'role', 'id')),  # Default ordering
+        )
         verbose_name = _('virtual circuit termination')
         verbose_name_plural = _('virtual circuit terminations')
 

+ 23 - 7
netbox/circuits/ui/panels.py

@@ -13,13 +13,9 @@ class CircuitCircuitTerminationPanel(panels.ObjectPanel):
     template_name = 'circuits/panels/circuit_circuit_termination.html'
     title = _('Termination')
 
-    def __init__(self, accessor=None, side=None, **kwargs):
-        super().__init__(**kwargs)
-
-        if accessor is not None:
-            self.accessor = accessor
-        if side is not None:
-            self.side = side
+    def __init__(self, side, accessor=None, **kwargs):
+        super().__init__(accessor=accessor, **kwargs)
+        self.side = side
 
     def get_context(self, context):
         return {
@@ -58,6 +54,26 @@ class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
         )
 
 
+class CircuitTerminationPanel(panels.ObjectAttributesPanel):
+    title = _('Circuit Termination')
+    circuit = attrs.RelatedObjectAttr('circuit', linkify=True)
+    provider = attrs.RelatedObjectAttr('circuit.provider', linkify=True)
+    termination = attrs.GenericForeignKeyAttr('termination', linkify=True, label=_('Termination point'))
+    connection = attrs.TemplatedAttr(
+        'pk',
+        template_name='circuits/circuit_termination/attrs/connection.html',
+        label=_('Connection'),
+    )
+    speed = attrs.TemplatedAttr(
+        'port_speed',
+        template_name='circuits/circuit_termination/attrs/speed.html',
+        label=_('Speed'),
+    )
+    xconnect_id = attrs.TextAttr('xconnect_id', label=_('Cross-Connect'), style='font-monospace')
+    pp_info = attrs.TextAttr('pp_info', label=_('Patch Panel/Port'))
+    description = attrs.TextAttr('description')
+
+
 class CircuitGroupPanel(panels.OrganizationalObjectPanel):
     tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
 

+ 6 - 5
netbox/circuits/views.py

@@ -8,7 +8,6 @@ from netbox.ui import actions, layout
 from netbox.ui.panels import (
     CommentsPanel,
     ObjectsTablePanel,
-    Panel,
     RelatedObjectsPanel,
 )
 from netbox.views import generic
@@ -53,6 +52,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.ProviderAccount',
                 filters={'provider_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider'],
                 actions=[
                     actions.AddObject(
                         'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
@@ -62,6 +62,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.Circuit',
                 filters={'provider_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider'],
                 actions=[
                     actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
                 ],
@@ -161,6 +162,7 @@ class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.Circuit',
                 filters={'provider_account_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider_account'],
                 actions=[
                     actions.AddObject(
                         'circuits.Circuit',
@@ -257,6 +259,7 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='circuits.VirtualCircuit',
                 filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['provider_network'],
                 actions=[
                     actions.AddObject(
                         'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
@@ -508,10 +511,7 @@ class CircuitTerminationView(generic.ObjectView):
     queryset = CircuitTermination.objects.all()
     layout = layout.SimpleLayout(
         left_panels=[
-            Panel(
-                template_name='circuits/panels/circuit_termination.html',
-                title=_('Circuit Termination'),
-            )
+            panels.CircuitTerminationPanel(),
         ],
         right_panels=[
             CustomFieldsPanel(),
@@ -801,6 +801,7 @@ class VirtualCircuitView(generic.ObjectView):
                 model='circuits.VirtualCircuitTermination',
                 title=_('Terminations'),
                 filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['virtual_circuit'],
                 actions=[
                     actions.AddObject(
                         'circuits.VirtualCircuitTermination',

+ 2 - 1
netbox/core/api/serializers_/jobs.py

@@ -26,13 +26,14 @@ class JobSerializer(BaseModelSerializer):
     object = serializers.SerializerMethodField(
         read_only=True
     )
+    notifications = ChoiceField(choices=JobNotificationChoices, read_only=True)
 
     class Meta:
         model = Job
         fields = [
             'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
             'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
-            'log_entries',
+            'notifications', 'log_entries',
         ]
         brief_fields = ('url', 'created', 'completed', 'user', 'status')
 

+ 1 - 1
netbox/core/apps.py

@@ -22,7 +22,7 @@ class CoreConfig(AppConfig):
 
     def ready(self):
         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.models.features import register_models
 

+ 29 - 1
netbox/core/checks.py

@@ -1,9 +1,11 @@
 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
 
 __all__ = (
     'check_duplicate_indexes',
+    'check_postgresql_version',
 )
 
 
@@ -39,3 +41,29 @@ def check_duplicate_indexes(app_configs, **kwargs):
                 )
 
     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

+ 12 - 0
netbox/core/choices.py

@@ -72,6 +72,18 @@ class JobStatusChoices(ChoiceSet):
     )
 
 
+class JobNotificationChoices(ChoiceSet):
+    NOTIFICATION_ALWAYS = 'always'
+    NOTIFICATION_ON_FAILURE = 'on_failure'
+    NOTIFICATION_NEVER = 'never'
+
+    CHOICES = (
+        (NOTIFICATION_ALWAYS, _('Always')),
+        (NOTIFICATION_ON_FAILURE, _('On failure')),
+        (NOTIFICATION_NEVER, _('Never')),
+    )
+
+
 class JobIntervalChoices(ChoiceSet):
     INTERVAL_MINUTELY = 1
     INTERVAL_HOURLY = 60

+ 3 - 2
netbox/core/forms/model_forms.py

@@ -165,9 +165,10 @@ class ConfigRevisionForm(forms.ModelForm, metaclass=ConfigFormMetaclass):
         FieldSet('PAGINATE_COUNT', 'MAX_PAGE_SIZE', name=_('Pagination')),
         FieldSet('CUSTOM_VALIDATORS', 'PROTECTION_RULES', name=_('Validation')),
         FieldSet('DEFAULT_USER_PREFERENCES', name=_('User Preferences')),
+        FieldSet('CHANGELOG_RETENTION', 'CHANGELOG_RETAIN_CREATE_LAST_UPDATE', name=_('Change Log')),
         FieldSet(
-            'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION',
-            'MAPS_URL', name=_('Miscellaneous'),
+            'MAINTENANCE_MODE', 'COPILOT_ENABLED', 'GRAPHQL_ENABLED', 'JOB_RETENTION', 'MAPS_URL',
+            name=_('Miscellaneous'),
         ),
         FieldSet('comment', name=_('Config Revision'))
     )

+ 41 - 8
netbox/core/jobs.py

@@ -5,6 +5,7 @@ from importlib import import_module
 import requests
 from django.conf import settings
 from django.core.cache import cache
+from django.db.models import Exists, OuterRef, Subquery
 from django.utils import timezone
 from packaging import version
 
@@ -14,7 +15,7 @@ from netbox.jobs import JobRunner, system_job
 from netbox.search.backends import search_backend
 from utilities.proxy import resolve_proxies
 
-from .choices import DataSourceStatusChoices, JobIntervalChoices
+from .choices import DataSourceStatusChoices, JobIntervalChoices, ObjectChangeActionChoices
 from .models import DataSource
 
 
@@ -126,19 +127,51 @@ class SystemHousekeepingJob(JobRunner):
         """
         Delete any ObjectChange records older than the configured changelog retention time (if any).
         """
-        self.logger.info("Pruning old changelog entries...")
+        self.logger.info('Pruning old changelog entries...')
         config = Config()
         if not config.CHANGELOG_RETENTION:
-            self.logger.info("No retention period specified; skipping.")
+            self.logger.info('No retention period specified; skipping.')
             return
 
         cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
-        self.logger.debug(
-            f"Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})"
-        )
+        self.logger.debug(f'Changelog retention period: {config.CHANGELOG_RETENTION} days ({cutoff:%Y-%m-%d %H:%M:%S})')
+
+        expired_qs = ObjectChange.objects.filter(time__lt=cutoff)
+
+        # When enabled, retain each object's original create record and most recent update record while pruning expired
+        # changelog entries. This applies only to objects without a delete record.
+        if config.CHANGELOG_RETAIN_CREATE_LAST_UPDATE:
+            self.logger.debug('Retaining changelog create records and last update records (excluding deleted objects)')
+
+            deleted_exists = ObjectChange.objects.filter(
+                action=ObjectChangeActionChoices.ACTION_DELETE,
+                changed_object_type_id=OuterRef('changed_object_type_id'),
+                changed_object_id=OuterRef('changed_object_id'),
+            )
+
+            # Keep create records only where no delete exists for that object
+            create_pks_to_keep = (
+                ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_CREATE)
+                .annotate(has_delete=Exists(deleted_exists))
+                .filter(has_delete=False)
+                .values('pk')
+            )
+
+            # Keep the most recent update per object only where no delete exists for the object
+            latest_update_pks_to_keep = (
+                ObjectChange.objects.filter(action=ObjectChangeActionChoices.ACTION_UPDATE)
+                .annotate(has_delete=Exists(deleted_exists))
+                .filter(has_delete=False)
+                .order_by('changed_object_type_id', 'changed_object_id', '-time', '-pk')
+                .distinct('changed_object_type_id', 'changed_object_id')
+                .values('pk')
+            )
+
+            expired_qs = expired_qs.exclude(pk__in=Subquery(create_pks_to_keep))
+            expired_qs = expired_qs.exclude(pk__in=Subquery(latest_update_pks_to_keep))
 
-        count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
-        self.logger.info(f"Deleted {count} expired changelog records")
+        count = expired_qs.delete()[0]
+        self.logger.info(f'Deleted {count} expired changelog records')
 
     def delete_expired_jobs(self):
         """

+ 21 - 0
netbox/core/migrations/0022_default_ordering_indexes.py

@@ -0,0 +1,21 @@
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0021_job_queue_name'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='configrevision',
+            index=models.Index(fields=['-created'], name='core_config_created_ef9552_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='job',
+            index=models.Index(fields=['-created'], name='core_job_created_efa7cb_idx'),
+        ),
+    ]

+ 15 - 0
netbox/core/migrations/0023_datasource_sync_permission.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0022_default_ordering_indexes'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='datasource',
+            options={'ordering': ('name',), 'permissions': [('sync', 'Synchronize data from remote source')]},
+        ),
+    ]

+ 16 - 0
netbox/core/migrations/0024_job_notifications.py

@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0023_datasource_sync_permission'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='job',
+            name='notifications',
+            field=models.CharField(default='always', max_length=30),
+        ),
+    ]

+ 13 - 13
netbox/core/models/change_logging.py

@@ -11,7 +11,7 @@ from mptt.models import MPTTModel
 from core.choices import ObjectChangeActionChoices
 from core.querysets import ObjectChangeQuerySet
 from netbox.models.features import ChangeLoggingMixin, has_feature
-from utilities.data import shallow_compare_dict
+from utilities.data import deep_compare_dict
 
 __all__ = (
     'ObjectChange',
@@ -199,18 +199,18 @@ class ObjectChange(models.Model):
         # Determine which attributes have changed
         if self.action == ObjectChangeActionChoices.ACTION_CREATE:
             changed_attrs = sorted(postchange_data.keys())
-        elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
+            return {
+                'pre': {k: prechange_data.get(k) for k in changed_attrs},
+                'post': {k: postchange_data.get(k) for k in changed_attrs},
+            }
+        if self.action == ObjectChangeActionChoices.ACTION_DELETE:
             changed_attrs = sorted(prechange_data.keys())
-        else:
-            # TODO: Support deep (recursive) comparison
-            changed_data = shallow_compare_dict(prechange_data, postchange_data)
-            changed_attrs = sorted(changed_data.keys())
-
+            return {
+                'pre': {k: prechange_data.get(k) for k in changed_attrs},
+                'post': {k: postchange_data.get(k) for k in changed_attrs},
+            }
+        diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data)
         return {
-            'pre': {
-                k: prechange_data.get(k) for k in changed_attrs
-            },
-            'post': {
-                k: postchange_data.get(k) for k in changed_attrs
-            },
+            'pre': dict(sorted(diff_removed.items())),
+            'post': dict(sorted(diff_added.items())),
         }

+ 3 - 0
netbox/core/models/config.py

@@ -37,6 +37,9 @@ class ConfigRevision(models.Model):
 
     class Meta:
         ordering = ['-created']
+        indexes = (
+            models.Index(fields=('-created',)),  # Default ordering
+        )
         verbose_name = _('config revision')
         verbose_name_plural = _('config revisions')
         constraints = [

+ 3 - 0
netbox/core/models/data.py

@@ -87,6 +87,9 @@ class DataSource(JobsMixin, PrimaryModel):
         ordering = ('name',)
         verbose_name = _('data source')
         verbose_name_plural = _('data sources')
+        permissions = [
+            ('sync', 'Synchronize data from remote source'),
+        ]
 
     def __str__(self):
         return f'{self.name}'

+ 22 - 8
netbox/core/models/jobs.py

@@ -16,7 +16,7 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from rq.exceptions import InvalidJobOperation
 
-from core.choices import JobStatusChoices
+from core.choices import JobNotificationChoices, JobStatusChoices
 from core.dataclasses import JobLogEntry
 from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED
 from core.models import ObjectType
@@ -118,6 +118,12 @@ class Job(models.Model):
         blank=True,
         help_text=_('Name of the queue in which this job was enqueued')
     )
+    notifications = models.CharField(
+        verbose_name=_('notifications'),
+        max_length=30,
+        choices=JobNotificationChoices,
+        default=JobNotificationChoices.NOTIFICATION_ALWAYS
+    )
     log_entries = ArrayField(
         verbose_name=_('log entries'),
         base_field=models.JSONField(
@@ -133,6 +139,7 @@ class Job(models.Model):
     class Meta:
         ordering = ['-created']
         indexes = (
+            models.Index(fields=('-created',)),  # Default ordering
             models.Index(fields=('object_type', 'object_id')),
         )
         verbose_name = _('job')
@@ -237,12 +244,16 @@ class Job(models.Model):
         self.save()
 
         # Notify the user (if any) of completion
-        if self.user:
-            Notification(
-                user=self.user,
-                object=self,
-                event_type=self.get_event_type(),
-            ).save()
+        if self.user and self.notifications != JobNotificationChoices.NOTIFICATION_NEVER:
+            if (
+                self.notifications == JobNotificationChoices.NOTIFICATION_ALWAYS or
+                status != JobStatusChoices.STATUS_COMPLETED
+            ):
+                Notification(
+                    user=self.user,
+                    object=self,
+                    event_type=self.get_event_type(),
+                ).save()
 
         # Send signal
         job_end.send(self)
@@ -266,6 +277,7 @@ class Job(models.Model):
             interval=None,
             immediate=False,
             queue_name=None,
+            notifications=None,
             **kwargs
     ):
         """
@@ -280,6 +292,7 @@ class Job(models.Model):
             interval: Recurrence interval (in minutes)
             immediate: Run the job immediately without scheduling it in the background. Should be used for interactive
                 management commands only.
+            notifications: Notification behavior on job completion (always, on_failure, or never)
         """
         if schedule_at and immediate:
             raise ValueError(_("enqueue() cannot be called with values for both schedule_at and immediate."))
@@ -301,7 +314,8 @@ class Job(models.Model):
             interval=interval,
             user=user,
             job_id=uuid.uuid4(),
-            queue_name=rq_queue_name
+            queue_name=rq_queue_name,
+            notifications=notifications if notifications is not None else JobNotificationChoices.NOTIFICATION_ALWAYS
         )
         job.full_clean()
         job.save()

+ 0 - 1
netbox/core/models/object_types.py

@@ -51,7 +51,6 @@ class ObjectTypeManager(models.Manager):
         """
         return self.get(app_label=app_label, model=model)
 
-    # TODO: Remove in NetBox v4.5
     def get_for_id(self, id):
         """
         Retrieve an ObjectType by its primary key (numeric ID).

+ 8 - 0
netbox/core/tables/jobs.py

@@ -1,4 +1,6 @@
 import django_tables2 as tables
+from django.utils.html import conditional_escape
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
 from core.constants import JOB_LOG_ENTRY_LEVELS
@@ -82,3 +84,9 @@ class JobLogEntryTable(BaseTable):
     class Meta(BaseTable.Meta):
         empty_text = _('No log entries')
         fields = ('timestamp', 'level', 'message')
+
+    def render_message(self, record, value):
+        if record.get('level') == 'error' and '\n' in value:
+            value = conditional_escape(value)
+            return mark_safe(f'<pre class="p-0">{value}</pre>')
+        return value

+ 104 - 1
netbox/core/tests/test_changelog.py

@@ -1,9 +1,16 @@
+import logging
+import uuid
+from datetime import timedelta
+from unittest.mock import patch
+
 from django.contrib.contenttypes.models import ContentType
-from django.test import override_settings
+from django.test import TestCase, override_settings
 from django.urls import reverse
+from django.utils import timezone
 from rest_framework import status
 
 from core.choices import ObjectChangeActionChoices
+from core.jobs import SystemHousekeepingJob
 from core.models import ObjectChange, ObjectType
 from dcim.choices import InterfaceTypeChoices, ModuleStatusChoices, SiteStatusChoices
 from dcim.models import (
@@ -694,3 +701,99 @@ class ChangeLogAPITest(APITestCase):
         self.assertEqual(changes[3].changed_object_type, ContentType.objects.get_for_model(Module))
         self.assertEqual(changes[3].changed_object_id, module.pk)
         self.assertEqual(changes[3].action, ObjectChangeActionChoices.ACTION_DELETE)
+
+
+class ChangelogPruneRetentionTest(TestCase):
+    """Test suite for Changelog pruning retention settings."""
+
+    @staticmethod
+    def _make_oc(*, ct, obj_id, action, ts):
+        oc = ObjectChange.objects.create(
+            changed_object_type=ct,
+            changed_object_id=obj_id,
+            action=action,
+            user_name='test',
+            request_id=uuid.uuid4(),
+            object_repr=f'Object {obj_id}',
+        )
+        ObjectChange.objects.filter(pk=oc.pk).update(time=ts)
+        return oc.pk
+
+    @staticmethod
+    def _run_prune(*, retention_days, retain_create_last_update):
+        job = SystemHousekeepingJob.__new__(SystemHousekeepingJob)
+        job.logger = logging.getLogger('netbox.tests.changelog_prune')
+
+        with patch('core.jobs.Config') as MockConfig:
+            cfg = MockConfig.return_value
+            cfg.CHANGELOG_RETENTION = retention_days
+            cfg.CHANGELOG_RETAIN_CREATE_LAST_UPDATE = retain_create_last_update
+            job.prune_changelog()
+
+    def test_prune_retain_create_last_update_excludes_deleted_objects(self):
+        ct = ContentType.objects.get_for_model(Site)
+
+        retention_days = 90
+        now = timezone.now()
+        cutoff = now - timedelta(days=retention_days)
+
+        expired_old = cutoff - timedelta(days=10)
+        expired_newer = cutoff - timedelta(days=1)
+        not_expired = cutoff + timedelta(days=1)
+
+        # A) Not deleted: should keep CREATE + latest UPDATE, prune intermediate UPDATEs
+        a_create = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        a_update1 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_old)
+        a_update2 = self._make_oc(ct=ct, obj_id=1, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+
+        # B) Deleted (all expired): should keep NOTHING
+        b_create = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        b_update = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+        b_delete = self._make_oc(ct=ct, obj_id=2, action=ObjectChangeActionChoices.ACTION_DELETE, ts=expired_newer)
+
+        # C) Deleted but delete is not expired: create/update expired should be pruned; delete remains
+        c_create = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired_old)
+        c_update = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired_newer)
+        c_delete = self._make_oc(ct=ct, obj_id=3, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
+
+        self._run_prune(retention_days=retention_days, retain_create_last_update=True)
+
+        remaining = set(ObjectChange.objects.values_list('pk', flat=True))
+
+        # A) Not deleted -> create + latest update remain
+        self.assertIn(a_create, remaining)
+        self.assertIn(a_update2, remaining)
+        self.assertNotIn(a_update1, remaining)
+
+        # B) Deleted (all expired) -> nothing remains
+        self.assertNotIn(b_create, remaining)
+        self.assertNotIn(b_update, remaining)
+        self.assertNotIn(b_delete, remaining)
+
+        # C) Deleted, delete not expired -> delete remains, but create/update are pruned
+        self.assertNotIn(c_create, remaining)
+        self.assertNotIn(c_update, remaining)
+        self.assertIn(c_delete, remaining)
+
+    def test_prune_disabled_deletes_all_expired(self):
+        ct = ContentType.objects.get_for_model(Site)
+
+        retention_days = 90
+        now = timezone.now()
+        cutoff = now - timedelta(days=retention_days)
+        expired = cutoff - timedelta(days=1)
+        not_expired = cutoff + timedelta(days=1)
+
+        # expired create/update should be deleted when feature disabled
+        x_create = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_CREATE, ts=expired)
+        x_update = self._make_oc(ct=ct, obj_id=10, action=ObjectChangeActionChoices.ACTION_UPDATE, ts=expired)
+
+        # non-expired delete should remain regardless
+        y_delete = self._make_oc(ct=ct, obj_id=11, action=ObjectChangeActionChoices.ACTION_DELETE, ts=not_expired)
+
+        self._run_prune(retention_days=retention_days, retain_create_last_update=False)
+
+        remaining = set(ObjectChange.objects.values_list('pk', flat=True))
+        self.assertNotIn(x_create, remaining)
+        self.assertNotIn(x_update, remaining)
+        self.assertIn(y_delete, remaining)

+ 88 - 1
netbox/core/tests/test_models.py

@@ -1,13 +1,16 @@
+import uuid
 from unittest.mock import MagicMock, patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.test import TestCase
 
-from core.choices import ObjectChangeActionChoices
+from core.choices import JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.models import DataSource, Job, ObjectType
 from dcim.models import Device, Location, Site
+from extras.models import Notification
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
+from users.models import User
 
 
 class DataSourceIgnoreRulesTestCase(TestCase):
@@ -226,6 +229,18 @@ class ObjectTypeTest(TestCase):
 
 class JobTest(TestCase):
 
+    def _make_job(self, user, notifications):
+        """
+        Create and return a persisted Job with the given user and notifications setting.
+        """
+        return Job.objects.create(
+            name='Test Job',
+            job_id=uuid.uuid4(),
+            user=user,
+            notifications=notifications,
+            status=JobStatusChoices.STATUS_RUNNING,
+        )
+
     @patch('core.models.jobs.django_rq.get_queue')
     def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
         """
@@ -257,3 +272,75 @@ class JobTest(TestCase):
         mock_get_queue.assert_called_with(custom_queue)
         mock_queue.fetch_job.assert_called_with(str(job.job_id))
         mock_rq_job.cancel.assert_called_once()
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_always(self, mock_job_end):
+        """
+        With notifications=always, a Notification should be created for every
+        terminal status (completed, failed, errored).
+        """
+        user = User.objects.create_user(username='notification-always')
+
+        for status in (
+            JobStatusChoices.STATUS_COMPLETED,
+            JobStatusChoices.STATUS_FAILED,
+            JobStatusChoices.STATUS_ERRORED,
+        ):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ALWAYS)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    1,
+                    msg=f"Expected a notification for status={status} with notifications=always",
+                )
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_on_failure(self, mock_job_end):
+        """
+        With notifications=on_failure, a Notification should be created only for
+        non-completed terminal statuses (failed, errored), not for completed.
+        """
+        user = User.objects.create_user(username='notification-on-failure')
+
+        # No notification on successful completion
+        job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+        job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
+        self.assertEqual(
+            Notification.objects.filter(user=user, object_id=job.pk).count(),
+            0,
+            msg="Expected no notification for status=completed with notifications=on_failure",
+        )
+
+        # Notification on failure/error
+        for status in (JobStatusChoices.STATUS_FAILED, JobStatusChoices.STATUS_ERRORED):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    1,
+                    msg=f"Expected a notification for status={status} with notifications=on_failure",
+                )
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_notification_never(self, mock_job_end):
+        """
+        With notifications=never, no Notification should be created regardless
+        of terminal status.
+        """
+        user = User.objects.create_user(username='notification-never')
+
+        for status in (
+            JobStatusChoices.STATUS_COMPLETED,
+            JobStatusChoices.STATUS_FAILED,
+            JobStatusChoices.STATUS_ERRORED,
+        ):
+            with self.subTest(status=status):
+                job = self._make_job(user, JobNotificationChoices.NOTIFICATION_NEVER)
+                job.terminate(status=status)
+                self.assertEqual(
+                    Notification.objects.filter(user=user, object_id=job.pk).count(),
+                    0,
+                    msg=f"Expected no notification for status={status} with notifications=never",
+                )

+ 7 - 9
netbox/core/views.py

@@ -50,7 +50,7 @@ from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from utilities.apps import get_installed_apps
-from utilities.data import shallow_compare_dict
+from utilities.data import deep_compare_dict
 from utilities.forms import ConfirmationForm
 from utilities.htmx import htmx_partial
 from utilities.json import ConfigJSONEncoder
@@ -103,6 +103,7 @@ class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
             ObjectsTablePanel(
                 model='core.DataFile',
                 filters={'source_id': lambda ctx: ctx['object'].pk},
+                exclude_columns=['source'],
             ),
         ],
     )
@@ -370,17 +371,14 @@ class ObjectChangeView(generic.ObjectView):
             prechange_data = instance.prechange_data_clean
 
         if prechange_data and instance.postchange_data:
-            diff_added = shallow_compare_dict(
-                prechange_data or dict(),
-                instance.postchange_data_clean or dict(),
+            diff_added, diff_removed = deep_compare_dict(
+                prechange_data,
+                instance.postchange_data_clean,
                 exclude=['last_updated'],
             )
-            diff_removed = {
-                x: prechange_data.get(x) for x in diff_added
-            } if prechange_data else {}
         else:
-            diff_added = None
-            diff_removed = None
+            diff_added = {}
+            diff_removed = {}
 
         return {
             'diff_added': diff_added,

+ 16 - 2
netbox/dcim/api/serializers_/cables.py

@@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.models import Cable, CablePath, CableTermination
+from dcim.models import Cable, CableBundle, CablePath, CableTermination
 from netbox.api.fields import ChoiceField, ContentTypeField
 from netbox.api.gfk_fields import GFKSerializerField
 from netbox.api.serializers import (
@@ -16,6 +16,7 @@ from tenancy.api.serializers_.tenants import TenantSerializer
 from utilities.api import get_serializer_for_model
 
 __all__ = (
+    'CableBundleSerializer',
     'CablePathSerializer',
     'CableSerializer',
     'CableTerminationSerializer',
@@ -24,6 +25,18 @@ __all__ = (
 )
 
 
+class CableBundleSerializer(PrimaryModelSerializer):
+    cable_count = serializers.IntegerField(read_only=True, default=0)
+
+    class Meta:
+        model = CableBundle
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'owner', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated', 'cable_count',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class CableSerializer(PrimaryModelSerializer):
     a_terminations = GenericObjectSerializer(many=True, required=False)
     b_terminations = GenericObjectSerializer(many=True, required=False)
@@ -31,12 +44,13 @@ class CableSerializer(PrimaryModelSerializer):
     profile = ChoiceField(choices=CableProfileChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False, allow_null=True)
+    bundle = CableBundleSerializer(nested=True, required=False, allow_null=True, default=None)
 
     class Meta:
         model = Cable
         fields = [
             'id', 'url', 'display_url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'profile',
-            'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
+            'tenant', 'bundle', 'label', 'color', 'length', 'length_unit', 'description', 'owner', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'label', 'description')

+ 8 - 6
netbox/dcim/api/serializers_/device_components.py

@@ -423,27 +423,29 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
         required=False,
         allow_null=True
     )
+    _occupied = serializers.BooleanField(required=False, read_only=True)
 
     class Meta:
         model = ModuleBay
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
-            'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'position', 'enabled',
+            'description', 'installed_module', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
-        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'enabled', 'description', '_occupied')
 
 
 class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
+    _occupied = serializers.BooleanField(required=False, read_only=True)
 
     class Meta:
         model = DeviceBay
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
-            'owner', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'enabled', 'description',
+            'installed_device', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
-        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'enabled', 'description', '_occupied',)
 
 
 class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):

Неке датотеке нису приказане због велике количине промена