Skip to content
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [11.3.0] - 2026-07-17

### Fixed

- Attachments never appeared on Custom Object detail pages when `netbox_attachments` was listed before `netbox_custom_objects` in `PLUGINS` (issue #110). Template extensions were built while this plugin's `ready()` ran, but `netbox_custom_objects` withholds its dynamically generated models until its own `ready()` has finished, so model discovery found nothing and no panel was ever registered — regardless of `scope_filter`. Custom objects are now served by a single, globally registered template extension that resolves the object at render time, so plugin order in `PLUGINS` no longer matters.
- Custom Object Types created after startup now get an attachment panel without restarting NetBox. Previously the model list was enumerated once during startup, so any type created later was invisible until the next restart.

### Added

- Startup warning (Django system check `netbox_attachments.W001`–`W003`) when `PLUGINS_CONFIG["netbox_attachments"]` still contains `apps`, `allowed_models`, or `mode`. These were replaced by `scope_filter` and `applied_scope` in v7.1.0, and NetBox keeps unknown keys without reading them, so a stale config silently fell back to the default `scope_filter` — which covers several core apps, making the configuration look partly honored (issue #110). The check names the replacement setting; run `manage.py check` to see it.

### Changed

- Custom object models are no longer enumerated at startup, removing this plugin's own database access during app loading and its dependency on `PLUGINS` ordering. (`apps.get_models()` is still called, so `netbox_custom_objects` may itself query when it loads first — but the plugin no longer drives that.)
- `display_setting` now accepts the same identifier for custom objects that `scope_filter` does — `netbox_custom_objects.<custom_object_type_name>`. It previously keyed off the internal, primary-key-derived model name (`netbox_custom_objects.table12model`), so the documented identifier silently never matched and per-type display overrides had no effect.

## [11.2.3] - 2026-06-02

### Added
Expand Down
52 changes: 48 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

Configure plugin settings under `PLUGINS_CONFIG["netbox_attachments"]`.

## Removed settings

These were replaced in v7.1.0 and are **ignored** if still present:

| Removed | Use instead |
| --- | --- |
| `apps` | `scope_filter` |
| `allowed_models` | `scope_filter` |
| `mode` | `applied_scope` (values are `app`/`model`, not `permissive`/`restrictive`) |

NetBox keeps unrecognized keys in `PLUGINS_CONFIG` without reading them, so a stale config does not raise an error — the replacement setting silently falls back to its default instead. Because that default already covers several core apps, part of the configuration keeps working while the rest does not, which is easy to mistake for a bug in the plugin.

Since v11.3.0 a Django system check warns about each removed setting at startup. To see them:

```bash
python3 manage.py check
```

## Settings

### `applied_scope`
Expand Down Expand Up @@ -62,14 +80,21 @@ Controls top-level **Attachments** dropdown creation when using `additional_tab`
- Type: `dict[str, str]`
- Default: `{}`

Per-model display override map.
Per-model display override map. Keys use the same identifiers as `scope_filter`: `app_label.model` for standard models, and `netbox_custom_objects.<custom_object_type_name>` for custom objects.

Example:

```python
{"dcim.device": "left_page", "ipam.vlan": "additional_tab"}
{
"dcim.device": "left_page",
"ipam.vlan": "additional_tab",
"netbox_custom_objects.display_calibration": "right_page",
}
```

!!! note
Before v11.3.0, custom objects were keyed here by an internal, primary-key-derived model name (`netbox_custom_objects.table12model`), so the identifier above silently had no effect.

## Example Configuration

```python
Expand All @@ -92,6 +117,25 @@ PLUGINS_CONFIG = {
}
```

## Custom Objects Limitation
## Custom Objects

Attachments work on [netbox-custom-objects](https://github.com/netboxlabs/netbox-custom-objects) models. Enable them like any other app:

- `applied_scope: "app"` — add `netbox_custom_objects` to `scope_filter`
- `applied_scope: "model"` — add `netbox_custom_objects.<custom_object_type_name>`

Custom object types created after NetBox starts are picked up automatically; no restart is needed.

### Display limitations

A custom object detail page is rendered by the Custom Objects plugin's own template, which hardcodes its tab bar and its button row. It renders only the `left_page`, `right_page`, and `full_width_page` panel hooks, so on custom objects:

- **`additional_tab` is unavailable** — there is no way for a plugin to add a tab to that page. The effective display falls back to `full_width_page`, unless you pick a side panel explicitly:

```python
"display_setting": {"netbox_custom_objects.display_calibration": "right_page"},
```

- **`create_add_button` has no effect** — the top-level "Attachments" dropdown cannot be rendered there. Use the panel's own "Add Attachment" and "Link Existing" buttons instead.

`additional_tab` mode is not available for custom object models using non-standard URL routing. For such models, the effective fallback is `full_width_page` unless an explicit left/right panel mode is configured.
Both limitations are properties of the Custom Objects detail template, not of this plugin, and would need an upstream change to lift.
10 changes: 8 additions & 2 deletions netbox_attachments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ class NetBoxAttachmentsConfig(PluginConfig):
author = "Jan Krupa"
base_url = "netbox-attachments"
default_settings = {
"applied_scope": "app", # Changed from 'mode' - options: 'app' or 'model'
"applied_scope": "app",
"scope_filter": [
"dcim",
"ipam",
"circuits",
"tenancy",
"virtualization",
"wireless",
], # Merged from 'apps' and 'allowed_models'
],
"display_default": "additional_tab",
"create_add_button": True,
"display_setting": {},
Expand All @@ -34,5 +34,11 @@ class NetBoxAttachmentsConfig(PluginConfig):
min_version = "4.5.0"
max_version = "4.6.99"

def ready(self):
super().ready()

# Import for the @register side effect; see checks.py for what it warns about.
from netbox_attachments import checks # noqa: F401


config = NetBoxAttachmentsConfig
62 changes: 62 additions & 0 deletions netbox_attachments/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Django system checks.

Warns about settings that were removed in v7.1.0 when ``apps`` and
``allowed_models`` were merged into ``scope_filter`` and ``mode`` became
``applied_scope``. NetBox's ``PluginConfig.validate()`` only fills in *missing*
keys, so a removed setting is not an error: it survives untouched in
``PLUGINS_CONFIG`` while nothing reads it, and the replacement silently falls
back to its default. That default covers several core apps, so part of the
configuration keeps working and the rest fails without a clue (issue #110).
"""

from django.core.checks import Warning, register

from netbox_attachments.utils import _get_plugin_settings
from netbox_attachments.version import __version__

_MERGED_HINT = "'apps' and 'allowed_models' were merged, so combine both into the single 'scope_filter' list."

# Removed setting -> (replacement, stable check ID, extra hint).
# The IDs are a published contract (CHANGELOG, SILENCED_SYSTEM_CHECKS): never
# renumber or reuse one, even if an entry is dropped from this table.
# `mode` is not a pure rename — its values changed along with its name.
REMOVED_SETTINGS = {
"apps": ("scope_filter", "netbox_attachments.W001", _MERGED_HINT),
"allowed_models": ("scope_filter", "netbox_attachments.W002", _MERGED_HINT),
"mode": (
"applied_scope",
"netbox_attachments.W003",
"Its values changed too: 'applied_scope' accepts 'app' or 'model', not 'permissive'/'restrictive'.",
),
}

# Pinned to this release's tag so the linked page describes the settings this
# install actually has; a moving main-branch URL would drift or 404.
_DOCS_URL = f"https://github.com/Kani999/netbox-attachments/blob/v{__version__}/docs/configuration.md"


@register()
def check_removed_settings(app_configs, **kwargs):
"""Warn for each removed plugin setting still present in PLUGINS_CONFIG."""
plugin_settings = _get_plugin_settings()

warnings = []
for removed, (replacement, check_id, extra_hint) in REMOVED_SETTINGS.items():
if removed not in plugin_settings:
continue

if replacement in plugin_settings:
effect = f"Your configured '{replacement}' is in effect; delete the stale '{removed}' key."
else:
effect = f"'{replacement}' falls back to its default value."

hint = f"Use '{replacement}' in PLUGINS_CONFIG['netbox_attachments'] instead. {extra_hint} See {_DOCS_URL}"
warnings.append(
Warning(
f"netbox-attachments: the '{removed}' setting was removed in v7.1.0 and is ignored. {effect}",
hint=hint,
id=check_id,
)
)

return warnings
11 changes: 5 additions & 6 deletions netbox_attachments/migrations/0012_netboxattachment_owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@


class Migration(migrations.Migration):

dependencies = [
('netbox_attachments', '0011_netboxattachmentassignment_index'),
('users', '0015_owner'),
("netbox_attachments", "0011_netboxattachmentassignment_index"),
("users", "0015_owner"),
]

operations = [
migrations.AddField(
model_name='netboxattachment',
name='owner',
model_name="netboxattachment",
name="owner",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to='users.owner',
to="users.owner",
),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@


class Migration(migrations.Migration):

dependencies = [
('netbox_attachments', '0012_netboxattachment_owner'),
("netbox_attachments", "0012_netboxattachment_owner"),
]

operations = [
migrations.AlterField(
model_name='netboxattachmentassignment',
name='custom_field_data',
model_name="netboxattachmentassignment",
name="custom_field_data",
field=models.JSONField(
blank=True,
default=dict,
Expand Down
4 changes: 1 addition & 3 deletions netbox_attachments/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,7 @@ def get_missing_parent_row_class(record):
if count is not None:
return "table-danger" if count == 0 else ""
# Fallback for views without annotation
logger.warning(
"assignment_count annotation missing for %r; falling back to exists() query", record
)
logger.warning("assignment_count annotation missing for %r; falling back to exists() query", record)
return "table-danger" if not record.attachment_assignments.exists() else ""


Expand Down
Loading
Loading