Skip to content

Closes #19731: Add ModuleBayType to restrict which module types can be installed into a module bay#22648

Open
bctiemann wants to merge 9 commits into
featurefrom
19731-module-bay-type-restriction
Open

Closes #19731: Add ModuleBayType to restrict which module types can be installed into a module bay#22648
bctiemann wants to merge 9 commits into
featurefrom
19731-module-bay-type-restriction

Conversation

@bctiemann

Copy link
Copy Markdown
Contributor

Summary

Closes: #19731

Introduces ModuleBayType — a color-coded, manufacturer-scoped type label that can be assigned to ModuleBays, ModuleBayTemplates, and ModuleTypes via M2M relations. When a bay and the module type being installed both carry at least one bay-type tag, installation is permitted only if their tag sets share at least one common member. An empty set on either side means unconstrained (backward-compatible default).

What's new

  • ModuleBayType model (dcim) — has name, slug, manufacturer (optional), and color fields; full PrimaryModel feature set (tags, custom fields, changelog, etc.)
  • M2M assignments on ModuleBay, ModuleBayTemplate, and ModuleType
  • Full surface area: REST API, GraphQL, FilterSets, forms (model, filter, bulk edit/import), tables, UI detail panels, navigation, and search

Design decisions: blocking vs. non-blocking validation

There are three distinct paths by which a module can end up in an incompatible bay, and we treat them differently by design:

Path Validation Rationale
Creating a new Module (POST to /api/dcim/modules/ or UI) Hard block (Module.clean()) The user is actively choosing what to install; a clear error is appropriate and easy to act on.
Editing an existing Module directly (PATCH/PUT or UI) Hard block (Module.clean()) Same — a direct edit is a deliberate choice with a clear correction path.
Retroactive M2M change: editing a ModuleType or ModuleBay's bay types so that an already-installed module is no longer compatible Soft warning (flash message + in-page panel) Blocking this would prevent operators from making legitimate bay-type changes when multiple modules are involved, and the resolution may require visiting several module records. A warning is far less frustrating.

The retroactive incompatibility cases are surfaced via:

  1. Module.is_bay_compatible / ModuleBay.is_module_compatible — model properties that evaluate compatibility on demand with no extra DB hits when the relevant M2M fields are prefetched.
  2. BayTypeIncompatibilityPanel — a Bootstrap warning card rendered at the top of the Module and ModuleBay detail views when incompatibility is detected.
  3. Post-save flash warnings in ModuleTypeEditView and ModuleBayEditView — count how many installed modules became retroactively incompatible after the edit.
  4. REST API flagsis_bay_compatible on Module and is_module_compatible on ModuleBay responses, so automation pipelines can detect and remediate violations.

API payload example

GET /api/dcim/module-bays/2/

{
  "id": 2,
  "display": "Bay 2",
  "module_bay_types": [{ "id": 5, "display": "type2" }],
  "installed_module": { "id": 38, "display": "Module1234" },
  "_occupied": true,
  "is_module_compatible": false
}

GET /api/dcim/modules/38/

{
  "id": 38,
  "display": "Module1234",
  "module_bay": { "id": 2, "display": "Bay 2" },
  "module_type": { "id": 6, "display": "Module1234" },
  "status": "active",
  "is_bay_compatible": false
}

Test plan

  • dcim.tests.test_models.ModuleBayTypeCompatibilityTestCase — 16 tests covering is_bay_compatible, is_module_compatible, get_incompatible_modules(), and get_incompatible_module()
  • dcim.tests.test_api.ModuleBayTypeTestCase — 23 API CRUD/filter tests for the new model
  • dcim.tests.test_api.ModuleTestCase.test_is_bay_compatible_flag — asserts the flag is true/false/true for compatible/incompatible/unconstrained modules
  • dcim.tests.test_api.ModuleBayTestCase.test_is_module_compatible_flag — same for module bays
  • dcim.tests.test_filtersets.ModuleBayTypeTestCase — 9 filterset tests
  • dcim.tests.test_views.ModuleBayTypeTestCase — 30 view tests (list, detail, create, edit, delete, bulk)
  • Existing ModuleTestCase, ModuleBayTestCase, ModuleTypeTestCase, ModuleBayTemplateTestCase query-count baselines updated for the new M2M prefetches

Generated with Claude Code

…nstall into a bay

Introduces a new ModuleBayType model — a color-coded, manufacturer-scoped
type label — that can be assigned to ModuleBays, ModuleBayTemplates, and
ModuleTypes via M2M relations. When both a bay and a module type carry at
least one bay-type tag, installation is permitted only if their tag sets
share a common member; an empty set on either side means unconstrained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann

bctiemann commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

UI Screenshot

Bay Type Incompatibility warning panel and flash message, shown on a ModuleBay detail view after editing the bay types such that the installed module became retroactively incompatible:

Screenshot 2026-07-08 at 9 26 49 PM

@bctiemann bctiemann changed the base branch from main to feature July 9, 2026 01:54
bctiemann and others added 3 commits July 8, 2026 22:35
- Fix N+1 queries: use .all() set comprehensions in is_bay_compatible and
  is_module_compatible so Django prefetch cache is honoured
- Delegate clean() compatibility check to is_bay_compatible property
- Use getattr() instead of hasattr() for installed_module access
- Replace unbounded RelatedObjectListAttr with paginated ObjectsTablePanel
  in ModuleBayType detail view
- Fix BayTypeIncompatibilityPanel to use isinstance() dispatch and pass
  explicit is_module_view context variable to template
- Add slug to ModuleBayTypeFilterSet search
- Add _source_template comment in ModuleBayTemplate.instantiate()
- Update query count baselines after prefetch fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New docs/models/dcim/modulebaytype.md describing the model and the
  opt-in allow-list compatibility semantics
- Update modulebay.md, moduletype.md with bay_types field docs
- Update module.md with is_bay_compatible API flag and compatibility note
- Update modulebaytemplate.md to note that bay types propagate on instantiation
- Add ModuleBayType to mkdocs.yml nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review and report only on areas of potential concern.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bctiemann's task in 4m 57s —— View job


Code Review: ModuleBayType Feature

The implementation is well-structured and handles the three installation paths with an appropriate blocking vs. warning strategy. Below are the areas of potential concern.


1. N+1 Queries in UI Detail Views

ModuleView and ModuleBayView both use bare queryset = Model.objects.all() without prefetching the M2M fields needed for the incompatibility panel.

The BayTypeIncompatibilityPanel.should_render() calls obj.is_bay_compatible / obj.is_module_compatible, each of which calls .all() on two unprefetched M2M relations (module_bay_types on the bay and on the module type). If the panel renders, the template calls the same relations again — 4 additional DB queries per page even for compatible modules.

  • views.py:3097: ModuleView.queryset = Module.objects.all() — no prefetch for module_bay__module_bay_types or module_type__module_bay_types
  • views.py:3949: ModuleBayView.queryset = ModuleBay.objects.all() — no prefetch for module_bay_types or installed_module__module_type__module_bay_types

The detail views should prefetch these chains so .all() in should_render() hits the cache, not the DB.

Fix this →


2. ModuleBayViewSet Missing Explicit module_bay_types Prefetch

# api/views.py ~line 521
queryset = ModuleBay.objects.prefetch_related(
    'installed_module__module_type__module_bay_types',
)

The is_module_compatible property calls self.module_bay_types.all() (the bay's own types), but module_bay_types is not in the explicit prefetch_related. This relies entirely on the dynamic serializer prefetcher picking it up from the declared module_bay_types = ModuleBayTypeSerializer(many=True) field. That should work given NetBox's architecture, but the parallel explicit prefetch for installed_module__module_type__module_bay_types suggests the author intended explicit coverage here — module_bay_types should be added alongside it for clarity and safety.


3. Fragile Success Detection in post() Overrides

Both ModuleTypeEditView.post() and ModuleBayEditView.post() detect a successful save by inspecting the response:

saved = (
    getattr(response, 'status_code', None) == 302 or
    (hasattr(response, 'headers') and 'HX-Location' in response.headers)
)

The _quickadd path in the base view returns a rendered template (not a redirect), so it's silently excluded from warnings. More importantly, _addanother also returns a 302 and would be caught; in the edit-form context (kwargs.get('pk') is set), this fires the warning before the user has finished their workflow, which is arguably correct — but worth confirming intentionally.

A more explicit hook would be overriding form_valid() or using a post_save signal, which eliminates the dependency on HTTP response inspection and is robust to future changes in the base view.


4. No Retroactive Incompatibility Warning on Bulk Edits

ModuleTypeBulkEditView can update module_bay_types on many objects at once, but no incompatibility count warning is issued. A user who bulk-removes bay type constraints from several ModuleType records will silently create retroactive incompatibilities.

This is partially mitigated by the in-page BayTypeIncompatibilityPanel on each Module/ModuleBay detail view, but the warning gap is worth documenting (or extending the bulk-edit logic).


5. GraphQL Type Name Collision Risk

The existing ModuleBayType in graphql/types.py is the GraphQL type for the ModuleBay component, while the new ModuleBayTypeType is the GraphQL type for the new ModuleBayType model. While this follows project convention (model name + "Type" suffix), the name ModuleBayType resolving to the bay component type in GraphQL queries — while ModuleBayTypeType is the bay-type classifier — could confuse API consumers.

Consider documenting this distinction explicitly in the docs, or raising it with the maintainers since it's a naming artifact of an unfortunate coincidence between the GraphQL suffix convention and this model's name.


Minor: get_incompatible_modules() Subquery Produces Duplicates

constrained_bay_pks = ModuleBay.objects.filter(
    module_bay_types__isnull=False
).values_list('pk', flat=True)

A M2M join on module_bay_types__isnull=False can return duplicate PKs (one per associated bay type). Adding .distinct() would make this cleaner, though it's functionally correct as-is since __in deduplicates.


Summary

The feature is well-tested and covers the full surface area cleanly. The primary actionable concerns are the missing prefetches on the UI detail views (#1) and the ModuleBayViewSet (#2), which could generate unnecessary DB queries under normal use. The post() override approach (#3) and the bulk-edit gap (#4) are lower priority but worth addressing for robustness.

- Add prefetch_related for module_bay_types chains to ModuleView and
  ModuleBayView detail views so BayTypeIncompatibilityPanel.should_render()
  hits the cache instead of issuing extra queries
- Add module_bay_types to ModuleBayViewSet explicit prefetch for parity
  with the installed_module chain (dynamic serializer prefetch already
  handles it; this makes the intent explicit)
- Add .distinct() to get_incompatible_modules() constrained_bay_pks
  subquery to prevent M2M join duplicates
- Add comment to post() overrides explaining the response-detection logic
  and why _quickadd is excluded without an explicit guard
- Document GraphQL ModuleBayType / ModuleBayTypeType naming collision in
  modulebaytype.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann

bctiemann commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

All items are addressed except 4 which is out of scope.

bctiemann and others added 2 commits July 9, 2026 05:54
Add add_module_bay_types / remove_module_bay_types fields to both
ModuleTypeBulkEditForm and ModuleBayBulkEditForm so bay type constraints
can be managed in bulk without overwriting the full M2M set.

Override post_save_operations in the corresponding bulk edit views to
apply the add/remove operations and accumulate a count of newly-
incompatible objects. A warning message is emitted after a successful
save if any incompatibilities were introduced. The incompatibility check
is guarded so it only runs when bay types were actually changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both forms included 'enabled' via form_from_model(), which generates a
plain BooleanField (always True or False). This caused the field to be
applied to every selected object even when the user did not intend to
change it.

Replace with an explicit NullBooleanField + BulkEditNullBooleanSelect,
matching the pattern used by ConsolePort, PowerPort, Interface, and
other component bulk edit forms. The null/blank state now means "leave
unchanged".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann

Copy link
Copy Markdown
Contributor Author

Bulk editing has now been implemented, including the retroactive incompatibility warning called out in item 4. I have also fixed a pre-existing bug where enabled was a simple boolean checkbox on ModuleBayBulkEditForm and DeviceBayBulkEditForm, instead of a NullBooleanField which would prevent that value from being bulk-updated if not explicitly set or unset.

@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review just the changes since the last review and previously raised items.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @bctiemann's task in 4m 55s —— View job


Code Review: Changes Since Last Review


Previous Review Items — All Addressed

# Item Status
1 N+1 queries in ModuleView/ModuleBayView ✅ Fixed — both views now prefetch_related the full module_bay_types chains
2 ModuleBayViewSet missing explicit module_bay_types prefetch ✅ Fixed — added alongside installed_module__module_type__module_bay_types
3 Fragile post() success detection ✅ Comments added; documented why _quickadd is excluded without an explicit guard
4 No warning on bulk edits ✅ Implemented (see below)
5 GraphQL ModuleBayType/ModuleBayTypeType naming collision ✅ Documented in modulebaytype.md
6 Duplicate PKs in get_incompatible_modules() ✅ Fixed — .distinct() added

New Changes: enabled Field Fix (d868498)

The fix is correct and follows the established pattern. Both ModuleBayBulkEditForm and DeviceBayBulkEditForm now declare enabled as an explicit NullBooleanField with BulkEditNullBooleanSelect, matching ConsolePort, PowerPort, Interface, and other component forms. nullable_fields correctly omits enabled — the NullBoolean widget's blank state handles "leave unchanged" via changed_data rather than nullification.


New Changes: Bulk Edit Warnings (d91418e)

The implementation is logically sound. Two concerns:

1. Pre-existing incompatibilities are included in the count

ModuleTypeBulkEditView.post_save_operations() calls obj.get_incompatible_modules().count() after the M2M change. ModuleBayBulkEditView.post_save_operations() calls int(not obj.is_module_compatible) after the change. Both reflect the current state of incompatibilities, not the delta introduced by this edit. If a module was already incompatible before the bulk edit, it is still included in the count.

The warning message says "X installed modules are now incompatible" — but some may have been incompatible beforehand. The message is conservative (over-warns rather than under-warns), which is acceptable for a safety-oriented feature. Worth noting in a code comment so future maintainers don't treat this as a precise "newly introduced" count.

2. Single-tab TabbedGroups is against the widget's contract

TabbedGroups is documented as "Two or more groups of fields arranged under tabs." Both new forms pass a single FieldSet to it. This works at runtime (no enforcement in __init__ beyond requiring each FieldSet to have a name), but it renders a single tab with no toggle — unnecessary UI chrome. The pattern used elsewhere (e.g., InterfaceBulkEditForm) uses two tabs: one for "Assignment" (replace-all) and one for "Add/Remove." The new forms could either:

  • Drop the TabbedGroups wrapper and use a plain FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')) (simpler), or
  • Add an "Assignment" tab for replace-all semantics (more complete, but more scope)

The first option is a one-line fix. Fix this →

3. Background job path (no warning) — acceptable

When a bulk edit is dispatched as a background job (background_job=True), _update_objects() is never reached in the same call path and _incompatible_count stays 0, so no flash warning fires. This is an inherent limitation of the flash-message approach and is consistent with the behaviour of all other flash-based warnings in the codebase.


Summary

All five items from the prior review are addressed. The two actionable concerns in the new commits are the single-tab TabbedGroups (minor, one-line fix) and the over-counting caveat in the warning message (acceptable as-is but worth a comment). The enabled field fix is clean and correct.

Replace single-tab TabbedGroups wrappers in ModuleTypeBulkEditForm and
ModuleBayBulkEditForm with plain FieldSet — a single tab has no toggle
and violates TabbedGroups contract of two or more tabs.

Add comment to both bulk edit post_save_operations explaining that the
incompatibility count reflects current state, not purely the delta from
this edit, so it may over-warn when pre-existing incompatibilities exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann

Copy link
Copy Markdown
Contributor Author

Fixed the two remaining items from the last review. Changed to a plain FieldSet in both bulk edit forms instead of TabbedGroups.

@bctiemann bctiemann requested review from a team and jnovinger and removed request for a team July 9, 2026 11:32

@jnovinger jnovinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like some merge conflicts creeped in.

Resolves conflicts with the feature branch's ModuleType.end_of_life
addition (both branches added fields to ModuleType's field lists.
Renumbers 0242_modulebaytype.py to 0243_modulebaytype.py to avoid
colliding with feature's 0242_add_devicetype_end_of_life.py, and
updates its dcim dependency accordingly.
EOF
)
@bctiemann bctiemann requested a review from jnovinger July 10, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restrict which module types can be installed into a module bay

2 participants