Browse Source

Merge pull request #22145 from netbox-community/more-claude-skills

Introduce additional Claude skills
bctiemann 1 week ago
parent
commit
1eeb54d052

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

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

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

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

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

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

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

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