- Document that ModuleType.to_yaml() exports module_bay_types by name but
the field isn't currently importable back through it (no ModuleTypeImportForm
field survived the CSV-import revert).
- modulebaytemplate.md's note covered only the device-type-parented import
path; ModuleBayTemplateImportForm is registered for both DeviceTypeImportView
and ModuleTypeImportView, scoping to whichever parent type's manufacturer
applies. Reworded to cover both, and added the "rejected rather than
resolved" clause for a name matching only some other manufacturer's type.
- Clarified clean_module_bay_types()'s docstring: the "never a cross-manufacturer
collision" guarantee holds only because ModularComponentTemplateModel.clean()
rejects a template with neither device_type nor module_type before this
method's result would ever be saved.
- Fixed a test docstring overstating symmetry between its two comparison arms.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per review, ditch dedupe_module_bay_types_by_manufacturer() and any import
logic that resolves module_bay_types by name alone across manufacturers.
ModuleBayType's unique constraint is (manufacturer, name), not name alone,
so resolving a bare name against an unscoped, potentially cross-manufacturer
pool -- guessing via a preference order, rejecting only on a genuine tie --
is not a reliable way to identify a specific object. CSV import forms have
no way to qualify an M2M reference beyond a bare name, so module_bay_types
is no longer exposed there at all (ModuleTypeImportForm, ModuleBayImportForm
in bulk_import.py): it's acceptable not to support this rather than resolve
it unreliably. This also reverts the netbox/tables/columns.py export_transform
API addition and the three tables' use of it, which existed only to make the
CSV round trip work.
The one import path that survives is ModuleBayTemplateImportForm (the YAML
device/module type "Import Components" flow), because it can reliably scope
module_bay_types' queryset to the parent device/module type's own
manufacturer plus global (manufacturer-less) types *before* resolving by
name -- so a name collision is never cross-manufacturer, only "this
manufacturer's own type vs. a global one of the same name," which
ModuleBayType's own uniqueness constraint makes unambiguous. A name matching
only some other manufacturer's type doesn't resolve at all, rather than
being coerced to an arbitrary guess.
Kept: the ModuleBayTemplateImportForm.enabled field/clean_enabled() fix
(default=True was previously lost on YAML re-import; unrelated to the
above), and to_yaml()'s export of module_bay_types on both ModuleType and
ModuleBayTemplate, plus the export_yaml() prefetch optimizations -- none of
these involve resolving an object's identity from an ambiguous attribute.
Trimmed the model docs to match: the modulebay.md and moduletype.md
paragraphs described capabilities (CSV import, cross-manufacturer YAML
import) that no longer exist and are removed; modulebaytemplate.md's note
is rewritten to describe the actual (manufacturer-or-global-scoped)
resolution behavior.
Allow channel subinterfaces to retain a specific physical interface type
and rename conventionally named children when their parent is renamed.
Keep mirrored cable and path state consistent when channel bindings
change, avoid unnecessary path rebuilds, and apply the same rename
behavior to interface templates.
- ModuleBayType.__str__() includes the manufacturer (e.g. "Cisco SFP28"),
but the three module_bay_types ManyToManyColumn declarations had no
transform, so django-tables2 defaulted to str() for CSV export while
the import forms resolve by name alone. Verified directly: exporting
a manufacturer-scoped bay type produced "Cisco SFP28", which then
failed to re-import with "Object not found: Cisco SFP28" -- broken
for exactly the case (manufacturer-scoped types) the preference/
ambiguity machinery exists to serve. Set transform=lambda obj: obj.name
on all three columns to match to_yaml(), and rewrote the existing
round-trip test to use a manufacturer-scoped bay type instead of a
global one, which is the only case that exercised str().
- The three ambiguity-refusal tests asserted only that the field
errored, which a plain invalid_choice (e.g. from a queryset that
excluded both candidates) would also satisfy -- masking a regression
of the manufacturer scoping removed two commits ago. Tightened each to
assert the error names both competing manufacturers.
- Corrected modulebay.md, which still described module_bay_types
resolution as "scoped to" the device's manufacturer -- the behavior
the prior commit removed as a bug; it's a preference, not a scope.
- Trimmed comments and docstrings introduced across this branch to a
more proportionate length.
Deliberately out of scope for this PR (tracked as follow-up
considerations, not fixed here): an escape hatch for a bay type name
that's genuinely ambiguous across manufacturers with no local match
(would require a new wire-format convention), and ModuleType.to_yaml()
not exporting a module-bays section at all (a separate, pre-existing
asymmetry, larger than this PR's scope).
- CSVModelMultipleChoiceField.clean() split on a bare comma with no
whitespace stripping, but ManyToManyColumn's default CSV export
separator is ", " (comma + space) -- so re-importing NetBox's own CSV
export of any multi-value column using this field (module_bay_types
among others, since this is a shared utility field) failed with
"Object not found: <value>" on every value after the first. Verified
directly against ModuleTypeTable's actual export value before fixing.
Also cast to str() before splitting: a YAML-bound caller (as opposed to
a CSV cell, always a string) can pass a non-string scalar, which
previously raised an unhandled AttributeError instead of a form error.
- Docs for module bay type resolution still described the pre-a3b5e4b
fallback ("then any remaining candidate"); updated to describe the
refusal behavior that replaced it. Added a matching note to
modulebay.md, which had none.
- dedupe_module_bay_types_by_manufacturer() collapses candidates by pk
within each name group before computing preference, so a caller
passing a duplicate row in a raw list -- the signature accepts "an
iterable," not just a queryset -- can't manufacture a same-manufacturer
tie that would then crash on None.manufacturer.name. Unreachable via
the three current callers today (each resolves from a queryset,
which can't contain a row twice), but cheap to make the helper safe
standalone.
- Fixed a stale test docstring contrasting the two import forms' field
types by a distinction (plain vs. CSV multiple-choice field) that no
longer exists since both were aligned to CSVModelMultipleChoiceField.
- Added ambiguity-refusal coverage at the other two call sites
(ModuleBayTemplateImportForm, ModuleBayImportForm) -- previously only
ModuleTypeImportForm was covered for this path.
Also found independently while verifying the above: ModuleTypeListView
.export_yaml() prefetched modulebaytemplates__module_bay_types, but
ModuleType.to_yaml() -- unlike DeviceType.to_yaml() -- never reads
self.modulebaytemplates at all (a separate, pre-existing, out-of-scope
gap: ModuleType.to_yaml() doesn't export a nested module-bays section).
That prefetch was dead weight, adding a query with no corresponding
saving. Removed it, and with it the now-meaningless "bay count doesn't
affect query count" test (nothing in ModuleType.to_yaml() ever varied
with bay count to begin with), replacing it with an exact-delta
assertion isolating what the one relevant prefetch (module_bay_types
on the module type itself) actually saves.
The port-mappings CSV column expanded only comma-separated individual
protocol/port pairs, while the edit form's port field already accepted
hyphen ranges (e.g. tcp/8000-8010). Route the CSV column through the same
expand_port_mapping() helper so both entry paths accept identical port
syntax. Parsing uses the shared split_port_mapping() helper, and the
blank-protocol error is worded to fit every entry path rather than only the
form widget's dropdown.
The manufacturer-or-null queryset scoping added to disambiguate a name shared
by a global and a manufacturer-scoped ModuleBayType went further than
intended: it also excluded a *different* manufacturer's bay type entirely.
The UI (ModuleTypeForm/ModuleBayTemplateForm) and REST API place no such
restriction -- a third-party module may legitimately declare compatibility
with another manufacturer's proprietary bay type. Confirmed the regression
directly: creating that assignment via ModuleTypeForm succeeds, but
exporting it and re-importing the same YAML failed with
"Object not found: SFP28", making valid existing data unimportable -- worse
than the bug this feature exists to fix.
Remove the queryset scoping entirely and instead make
dedupe_module_bay_types_by_manufacturer() manufacturer-aware: given the
target manufacturer, it now prefers (in order) an exact match, then a global
type, then any remaining candidate, resolved from an unscoped queryset in
clean() rather than a sibling clean_<field>() mutating the field's queryset.
This also drops the Meta.fields-ordering dependency those methods required.
Also, from the same review round:
- Test asserting Django's literal English error string now asserts the
error code instead, so it survives wording changes/translation.
- The prefetch query-count test moved from test_models.py (which doesn't
otherwise touch views) to test_views.py, and strengthened from "prefetch
saves at least one query" to "query count is constant regardless of bay
count" -- the actual invariant. Added equivalent coverage for
ModuleTypeListView, which the prior version didn't test at all.
- Corrected the export_yaml() prefetch comments to not imply the other
~11 relations to_yaml() touches are also covered -- they aren't, and
weren't before this feature either.
- Updated the model docs to describe the new (permissive, cross-manufacturer
allowed) behavior instead of the old (restrictive) one they described a
commit ago.
Adds regression tests importing a bay type belonging to a different
manufacturer than the importing device/module type, through both
ModuleBayTemplateImportForm and ModuleTypeImportForm.
- clean_module_bay_types()'s two ValidationErrors were unreachable:
ModelMultipleChoiceField.clean() already raises before the clean_<name>
hook runs on a non-list or an unresolvable name, per Django's
BaseForm._clean_fields(). Simplify to dedupe from cleaned_data (already
scoped and validated) via a shared dedupe_module_bay_types_by_manufacturer()
helper in dcim/utils.py, used by both ModuleBayTemplateImportForm and the
new ModuleTypeImportForm.module_bay_types below. This also drops the
self.data access that ignored the form prefix, broke on a QueryDict, and
re-queried once per name.
- ModuleType.module_bay_types (the module's own side of the bay/module
compatibility intersection) was still missing from the YAML round trip.
Add it to ModuleType.to_yaml() and ModuleTypeImportForm, mirroring
ModuleBayTemplateImportForm's manufacturer-scoping and dedup.
- ModuleBayTemplate.to_yaml() emitted enabled but the import form didn't
accept it, so it silently reset to False (not the model's default=True)
on any dict-bound re-import. Add it with the same
clean_enabled()-defaults-to-True pattern already used by
ModuleBayImportForm's CSV import.
- Prefetch module_bay_types in DeviceTypeListView/ModuleTypeListView's
export_yaml() so bulk YAML export doesn't add one query per module bay
template across the exported queryset.
- Document the manufacturer-preference precedence rule in the model docs,
since export emits a bare name and import can resolve a colliding one to
either a global or manufacturer-specific type.
Adds regression tests for the module_type-scoped path, the enabled
round trip, an export/import round trip, export ordering, the new
ModuleTypeImportForm coverage, and the prefetch fix.
* Add support for liquid cooling components
* Include sample of offending components when module move is disallowed
* Use settings.BULK_UPDATE_CHUNK_SIZE for batch_size
* Adopt review feedback
Resolves all conflicts between main and feature for #22896. Notable
resolutions:
- dcim/signals.py, dcim/tests/test_signals.py: main's cache_presave_scope_fields
/ sync_cached_scope_fields addition is fully superseded by feature's
PostgreSQL-trigger-based denormalization (confirmed via feature's own
migration docstrings); dropped in favor of feature's existing approach.
Both files now match feature's originals exactly.
- netbox/tables/columns.py: combined main's generic get_ordering_annotation()
protocol with feature's nulls_first-aware order() override. These two
mechanisms cannot both apply to the same column (django-tables2 negates an
entire order_by tuple uniformly on direction toggle, so a fixed nulls_first
placement and multi-column sort composition are mutually exclusive for one
column) -- preserved nulls_first (existing, wired through forms/API/GraphQL)
and removed main's two composition-only tests for CustomFieldColumn. See the
comment on CustomFieldColumn.order() for full reasoning.
- extras/customfields.py, extras/graphql/mixins.py: combined main's
request-cache optimization and has_key-scoped batch updates with feature's
resolve_selection_value() (shared select-field label resolution between
REST and GraphQL).
- extras/events.py, extras/event_rules.py: main's "Honor Script defaults when
triggered by Event Rules" (#22852) fix was written against the old inline
action-type dispatch, which feature had already replaced with a pluggable
action-provider registry (#22770). Re-applied the same two-line fix
(notifications/job_timeout) inside ScriptAction.enqueue() in event_rules.py
instead.
- utilities/jinja2.py: fixed a config-attribute name mismatch the raw merge
would have introduced (main's JINJA2_FILTERS vs feature's renamed
JINJA_FILTERS) by updating the shared _jinja2_filters() helper.
- ipam/migrations/: renumbered main's 0094_ipaddress_host_index to 0096 and
added a merge migration, since main and feature had each independently
added a migration numbered 0094.
- dcim/tests/query_counts.json: regenerated via UPDATE_QUERY_COUNTS=1 against
the merged codebase rather than hand-merging counts.
Verified: manage.py check clean, full migration graph applies cleanly from
scratch, ruff clean, and full test suites pass for dcim, ipam, netbox, extras,
circuits, vpn, wireless, tenancy, virtualization, core, users, and account
(fresh databases, no state carried over between runs).
Normalize RQ timeout values before validating global and per-webhook
timeouts, including duration strings and RQ's default and unlimited values.
Improve timeout logging and visibility in the UI and documentation, raise
the default webhook timeout to 60 seconds, and add coverage for the new
validation and filtering behavior.
* Closes#22770: Allow plugins to register Event Rule action handlers
Introduces an EventRuleAction registration API (netbox.event_rules /
netbox.extras.event_rules) so plugins can add new EventRule action types
the same way they already register search indexes and event types,
replacing the hardcoded webhook/script/notification elif-chain. Core's
own three action types are refactored onto this mechanism.
An EventRule referencing an unregistered action (e.g. its providing
plugin is uninstalled) remains stored, is skipped during processing
without affecting other rules, is visibly marked unavailable in the
UI/API, and triggers a new extras.W001 system check, resuming
automatically once the plugin is reinstalled, with no need to re-save.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix CI failure: extras.W001 check must tolerate a not-yet-migrated database
check_event_rule_actions() queried EventRule unconditionally, which broke
`manage.py makemigrations --check` (and a fresh `manage.py migrate`) on a
database with no tables yet, since Django runs system checks before
verifying/applying migrations. Wrap the query and swallow DatabaseError,
matching the existing check_postgresql_version precedent for a database
that may not be ready. Verified against a fresh, unmigrated database that
makemigrations --check, migrate, and manage.py check all behave correctly,
and that the warning still fires once a qualifying EventRule exists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Fix EventRuleForm action_type widget: HTMXSelect was silently ignored
Meta.widgets only applies to fields the ModelForm auto-generates; action_type
is an explicit class-level field, so its HTMXSelect assignment in Meta.widgets
never took effect, and switching Action type in the browser never refreshed
the action_choice field's label/queryset. Move the widget onto the field
declaration itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Address review feedback from Jeremy Stretch on PR #22793
- Revert action_object_type on_delete to CASCADE (was changed to SET_NULL)
- Make action_type choices dynamic via the model field's own callable
choices=, simplifying EventRule.clean() and making any unavailable
action_type invalid on save, whether new or unchanged
- Rename is_action_available to action_is_available
- Fold new dispatch tests into the existing RQQueueTestMixin test class to
fix a flaky --parallel run (two such classes cross-flush each other's
Redis queues)
- Only catch broad exceptions around plugin-provided actions in
process_event_rules(); let a core action's own bugs propagate
- Add value_action_type() so table exports don't leak the "unavailable"
badge's HTML markup
- Drop the frozen CSVChoiceField on action_type and make action_object
optional at the field level, so bulk import of an object-less action
works
- Map ValidationErrors on unexposed model fields to NON_FIELD_ERRORS in
bulk import instead of letting them surface as a raw ValueError
- Restore EventRuleActionEnum/the enum-based GraphQL filter, built from
the live action registry instead of the static EventRuleActionChoices
- Default EventRuleAction.object_required to False, matching
object_model's default of None; set it explicitly on the three core
actions
- Drop the unused request parameter on get_object_queryset()
- Fix action_object_type's serializer queryset, which incorrectly used
the triggering object_types' feature flag
- Use .format() instead of % in get_action_type_display()
- Remove the extras.W001 system check (a DB query on every management
command) in favor of an action_is_available field on the REST API
- Raise ValidationError instead of a bare Exception on duplicate action
slug registration
- Shorten a couple of overly verbose inline comments
- Split EventRuleAction.validate() into an internal _validate() and a
public no-op validate(), so a subclass's custom validation doesn't
need to remember to call super()
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Trim verbose comments/docstrings added while addressing review feedback
Shortened a number of overly long inline comments and test docstrings
introduced across the previous commit's review-feedback fixes (the
EventRuleActionEnum comment, the _validate()/validate() docstrings, and
several test docstrings that restated context already given elsewhere).
Also drops the auto-generated "Generated by Django" header comment from
migration 0143, matching the rest of this app's hand-touched migrations.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Address findings from automated follow-up review of #22793
- Clear stale action_object_type/action_object_id when an action declares
object_model but is left with no object selected (object_required=False);
previously neither branch of the if/elif fired and the old value from
before the edit silently persisted. Fixed in both EventRuleForm and
EventRuleImportForm (the latter matters for CSV updates of an existing
row). Also resolve the content type from the actual selected object
rather than the action's declared object_model, correctly handling
subclass/proxy instances.
- Validate action slugs at registration time (format, and collision via
enum_key() with an already-registered slug) so a bad third-party slug
is rejected immediately instead of crashing GraphQL schema assembly at
startup.
- Strip the dead-code label/description duplication out of
EventRuleActionChoices.CHOICES -- nothing reads it, and it risked
drifting from WebhookAction/ScriptAction/NotificationAction's own.
- Record whether an action is plugin-provided at registration time
instead of introspecting its module on every dispatch; core's three
actions now register with is_plugin_provided=False explicitly.
- Add an action_is_available filter (API + UI) so event rules with a
now-unavailable action can still be found in bulk, now that the
extras.W001 system check is gone.
- Update the plugin dev docs: fix the OpenTicketAction example (it was
missing object_required=True, the exact gap the action_object fix
above addresses), note that an unavailable rule can't be saved at all
(not just skipped), and move an internal-only note out of the
published class docstring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Address findings from second automated follow-up review of #22793
- Drop the ChoiceSet base from EventRuleActionChoices. With CHOICES=(),
the previous version made ChoiceField(choices=EventRuleActionChoices)
-- the idiomatic pattern used for every other ChoiceSet in this
codebase, and reachable via `from extras.choices import *` -- silently
reject every value instead of failing at first use.
- Reject slugs containing hyphens or a leading underscore at
registration time: a hyphenated slug (plausible, since plugin
distribution names are conventionally hyphenated) or a slug starting
with an underscore both currently pass validation but produce a
GraphQL-invalid or GraphQL-reserved enum member name once sanitized,
crashing schema assembly at startup. Document the constraint in the
plugin docs and the published slug docstring.
- Raise ImproperlyConfigured instead of ValidationError for all three
registration-failure cases in register_event_rule_action() -- these
are packaging/configuration mistakes surfaced from AppConfig.ready(),
not user input, matching the convention ChoiceSetMeta already uses for
the same class of error.
- Remove is_plugin_provided's class-level default; nothing reads it
before an action is registered in any real code path, and the default
masked a class-vs-instance inconsistency. Move its documentation out
of the published Attributes docstring into a plain comment.
- Simplify EventRuleImportForm.clean()'s action_object_type/id
assignment to match EventRuleForm.clean()'s approach (set both fields
once, unconditionally, from the resolved object) rather than assigning
via the GFK setter and then conditionally overwriting the content
type.
- Split a dense doc sentence in eventrule.md onto its own line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Address findings from third automated follow-up review of #22793
- Restore is_plugin_provided's class-level default of True. Its only
read is inside process_event_rules()'s exception handler; without a
default, an action reaching dispatch without going through
register_event_rule_action() (e.g. inserted into the registry dict
directly) raised AttributeError while already handling the real
exception, masking it entirely instead of degrading gracefully.
- Move the slug/label presence checks out of __init_subclass__ (which
fired at class-definition time, raising TypeError) and into
register_event_rule_action() as ImproperlyConfigured, unifying them
with the other three registration-time checks. This also resolves a
still-open item from the very first automated review: an intermediate
base class shared by several concrete plugin actions couldn't
previously be defined without a placeholder slug/label of its own.
- Restore the GFK assignment (self.instance.action_object = obj) in
EventRuleImportForm.clean() alongside the explicit content-type
assignment, so EventRule.clean()'s later access to action_object hits
the descriptor cache instead of an extra SELECT per imported row.
- Clarify the slug docstring/docs wording (leading underscore
specifically, not underscores in general; tell authors to use an
underscore instead of a hyphen) and document that intermediate base
classes are now supported. Add a test for an uppercase slug.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Document the frozen-at-import tradeoff on EventRuleSerializer.action_type
The EventRuleActionAPITestCase docstring in test_api.py pointed here for
an explanation of why the choices are materialized once at module-import
time rather than dynamically, but the field itself had no such comment.
* Address findings from fourth automated follow-up review of #22793
- Restore the "must start with a letter" slug constraint dropped from
the docs page and class docstring by the previous round's rewording;
reword to "must begin with a lowercase letter", which covers the
leading-digit case SLUG_RE actually rejects and matches the
ImproperlyConfigured message an author will hit.
- Scope the GFK-cache-priming comment in EventRuleImportForm.clean() to
the non-proxy case it actually holds for, rather than claiming it
unconditionally.
- Trim comments and docstrings that had regrown into reviewer-facing
rationale (why a prior finding was reverted, why a check isn't in
__init_subclass__ anymore rather than API documentation, in
event_rules.py and test_event_rules.py.
EOF
)
* Misc cleanup
* Misc cleanup
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Adds a nullable DurationField computed as completed - started, set in
Job.terminate(). Exposes it as an orderable table column, detail-panel
attr, REST API field, and UI/API range filters (execution_time__gte /
execution_time__lte).
A plain stored field (not a GeneratedField) keeps the migration
metadata-only, avoiding a full table rewrite on core_job.
Fix miscounting of total_vlan_ids when VLANGroup vid_ranges use
non-canonical bounds (e.g. '[]'). Normalize ranges to '[)' on save and
add migration to recompute existing totals. Prevent division-by-zero in
utilization queries for legacy rows with miscounted totals.
Fixes#22228
Allow IP ranges where start_address equals end_address to model
single-IP pools like DHCP or NAT reservations. Add validation tests,
filterset coverage, and display logic to render both endpoints.
Fixes#21993
Display a warning in the UI whenever a user goes to provision a v1
token (both via the admin token form and the user profile token form).
Update documentation to note that v1 tokens are deprecated and will be
removed in NetBox v5.0.
* Implement {module} position inheritance for nested module bays (#19796)
Enables a single ModuleType to produce correctly named components at any
nesting depth by resolving {module} in module bay position fields during
tree traversal. The user controls the separator through the position
field template itself (e.g. {module}/1 vs {module}-1 vs {module}.1).
Model layer:
- Add _get_inherited_positions() to resolve {module} in positions as
the module tree is walked from root to leaf
- Update _resolve_module_placeholder() with single-token logic: one
{module} resolves to the leaf bay's inherited position; multi-token
continues level-by-level replacement for backwards compatibility
Form layer:
- Update _get_module_bay_tree() to resolve {module} in positions during
traversal, propagating parent positions through the tree
- Extract validation into _validate_module_tokens() private method
Tests:
- Position inheritance at depth 2 and 3
- Custom separator (dot notation)
- Multi-token backwards compatibility
- Documentation for position inheritance
Fixes: #19796
* Consolidate {module} placeholder logic into shared utilities and add API validation
Extract get_module_bay_positions() and resolve_module_placeholder() into
dcim/utils.py as shared routines used by the model, form, and API serializer.
This eliminates duplicated traversal and resolution logic across three layers.
Key changes:
- Add position inheritance: {module} tokens in bay position fields resolve
using the parent bay's position during hierarchy traversal
- Single {module} token now resolves to the leaf bay's inherited position
- Mismatched token count vs tree depth now raises ValueError instead of
silently producing partial strings
- API serializer validation uses shared utilities for parity with the form
- Fix error message wording ("levels deep" instead of "in tree")
Introduce `VirtualMachineType` to classify virtual machines and apply
default platform, vCPU, and memory values when creating a VM.
This adds the new model and its relationship to `VirtualMachine`, and
wires it through forms, filtersets, tables, views, the REST API,
GraphQL, navigation, search, documentation, and tests.
Explicit values set on a virtual machine continue to take precedence,
and changes to a type do not retroactively update existing VMs.
Enable VMs to be assigned to a standalone device without requiring a
cluster. Add device-scoped uniqueness constraints, update validation
logic, and enhance placement flexibility. Site is now auto-inherited
from the cluster or device.
Adds a `request` key to the webhook data if a request is associated with the origination of the webhook.
Note: We're not attaching a complete representation of the request in the interest of both security and brevity.