Przeglądaj źródła

Closes CAP-100: Adopt AI best practices (#22120)

Jeremy Stretch 2 tygodni temu
rodzic
commit
b3bc4f8ef2

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

+ 3 - 0
.gitignore

@@ -11,6 +11,9 @@ __pycache__/
 yarn-debug.log*
 yarn-error.log*
 
+# AI tooling
+.claude/settings.local.json
+
 # Documentation generated by the upgrade/build workflow
 /netbox/project-static/docs/
 

+ 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