Skip to content

Commit ca4589d

Browse files
Kani999Jan Krupa
authored andcommitted
Add Add-button on Typed tabs with reverse-reference prefill (closes #9)
Each typed tab now exposes an "Add <Type>" button that links to the native customobject_add view with the back-reference field pre-populated to the parent object's PK and return_url set to the current tab path. After saving, the user lands back on the tab with filters preserved. When a Custom Object Type has multiple back-reference fields to the same parent model (e.g. primary_device + backup_device both -> dcim.device), the button becomes a Bootstrap split-dropdown listing each field. The button is hidden for users without add_customobject permission. Implementation: - New module-level _build_add_links() helper computes URLs from (slug, instance_pk, field_infos, return_url). Pure function, fully unit-tested. - field_infos tuples extended from (name, type) to (name, type, label) so the dropdown can show human-readable field labels. Star-unpacking in _count_for_type and the queryset filter loop preserves backward compatibility with 2-tuple shapes used by existing tests. - Permission gate uses utilities.permissions.get_permission_for_model, matching the pattern used by netbox_custom_objects.tables.CustomObjectActionsColumn. - field_infos is sorted by field name in register_typed_tabs so the dropdown order is deterministic. Tab placement: top-right of tab content, scoped entirely to typed/tab.html. Tabs with hide_if_empty=True remain hidden until the first object is created via the native menu - the button surfaces once the tab is visible. Bumps version to 2.2.0 (minor, new feature). Test suite extended from 53 to 61 tests covering reverse failure, single/multi field, deduplication, label fallback, 2-tuple back-compat, and return_url URL-encoding.
1 parent ed27d11 commit ca4589d

7 files changed

Lines changed: 529 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.2.0] - 2026-04-22
9+
10+
### Added
11+
12+
- **Add button on Typed tabs** ([#9](https://github.com/CESNET/netbox-custom-objects-tab/issues/9)) —
13+
each Typed tab now shows an "Add *Type*" button (top-right) that opens the native
14+
`customobject_add` view with the reverse-reference field pre-filled to the parent
15+
object's PK and `return_url` set back to the tab. After saving, the user lands back
16+
on the same tab, with any active filters preserved. When a Custom Object Type has
17+
multiple fields referencing the same parent model (e.g. `primary_device` and
18+
`backup_device` both → Device), the button becomes a split-dropdown listing each
19+
field. The button is hidden for users without `add_customobject` permission.
20+
Tabs with `hide_if_empty=True` are still hidden until the first object is created
21+
via the native menu — subsequent additions can use the new button.
22+
823
## [2.1.0] - 2026-03-16
924

1025
### Added

netbox_custom_objects_tab/templates/netbox_custom_objects_tab/typed/tab.html

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@
55
{% load i18n %}
66
{% block content %}
77
{% if table %}
8+
{# Add button (single) or split-dropdown (multiple back-reference fields) #}
9+
{% if can_add and add_links %}
10+
<div class="d-flex justify-content-end mt-2 mb-2">
11+
{% if add_links|length == 1 %}
12+
<a href="{{ add_links.0.url }}" class="btn btn-sm btn-primary">
13+
<i class="mdi mdi-plus-thick"></i>
14+
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
15+
</a>
16+
{% else %}
17+
<div class="btn-group">
18+
<button type="button"
19+
class="btn btn-sm btn-primary dropdown-toggle"
20+
data-bs-toggle="dropdown"
21+
aria-expanded="false">
22+
<i class="mdi mdi-plus-thick"></i>
23+
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
24+
</button>
25+
<ul class="dropdown-menu dropdown-menu-end">
26+
{% for link in add_links %}
27+
<li>
28+
<a class="dropdown-item" href="{{ link.url }}">{% blocktrans with f=link.label %}via {{ f }}{% endblocktrans %}</a>
29+
</li>
30+
{% endfor %}
31+
</ul>
32+
</div>
33+
{% endif %}
34+
</div>
35+
{% endif %}
836
<hr class="mt-0 mb-3">
937
{# Results / Filters inner tabs #}
1038
<ul class="nav nav-tabs custom-objects-subtabs mb-3" role="tablist">

netbox_custom_objects_tab/views/typed.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import logging
22
from collections import defaultdict
3+
from urllib.parse import urlencode
34

45
from django.contrib.contenttypes.models import ContentType
56
from django.db.models import Q
67
from django.db.utils import OperationalError, ProgrammingError
78
from django.shortcuts import get_object_or_404, render
9+
from django.urls import NoReverseMatch, reverse
810
from django.views.generic import View
911
from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices
1012
from netbox.forms import NetBoxModelFilterSetForm
@@ -14,6 +16,7 @@
1416
from netbox_custom_objects.models import CustomObjectTypeField
1517
from netbox_custom_objects.tables import CustomObjectTable
1618
from utilities.forms.fields import TagFilterField
19+
from utilities.permissions import get_permission_for_model
1720
from utilities.views import ViewTab, register_model_view
1821

1922
from ._co_common import _CO_BASE_TEMPLATE, _CUSTOM_OBJECTS_APP, _get_base_template # noqa: F401
@@ -99,10 +102,44 @@ def _build_filterset_form(custom_object_type, dynamic_model):
99102
)
100103

101104

105+
def _build_add_links(custom_object_type_slug, instance_pk, field_infos, return_url):
106+
"""
107+
Build pre-filled "Add" URLs for the native customobject_add view.
108+
109+
field_infos = list of (field_name, field_type, [label]) for fields referencing the parent.
110+
Returns list of {"field_name", "label", "url"} dicts (one per unique field), or [] if URL
111+
cannot be reversed (e.g. plugin URL conf not loaded).
112+
"""
113+
try:
114+
add_base = reverse(
115+
"plugins:netbox_custom_objects:customobject_add",
116+
kwargs={"custom_object_type": custom_object_type_slug},
117+
)
118+
except NoReverseMatch:
119+
return []
120+
121+
links = []
122+
seen = set()
123+
for field_name, _field_type, *rest in field_infos:
124+
if field_name in seen:
125+
continue
126+
seen.add(field_name)
127+
field_label = (rest[0] if rest else field_name) or field_name
128+
qs = urlencode({field_name: instance_pk, "return_url": return_url})
129+
links.append(
130+
{
131+
"field_name": field_name,
132+
"label": field_label,
133+
"url": f"{add_base}?{qs}",
134+
}
135+
)
136+
return links
137+
138+
102139
def _count_for_type(custom_object_type, field_infos):
103140
"""
104141
Return a badge callable for one Custom Object Type.
105-
field_infos = list of (field_name, field_type) for fields referencing the parent model.
142+
field_infos = list of (field_name, field_type, [label]) for fields referencing the parent model.
106143
Uses COUNT(*) only. Returns None when 0.
107144
"""
108145

@@ -117,7 +154,7 @@ def _badge(instance):
117154
return None
118155

119156
total = 0
120-
for field_name, field_type in field_infos:
157+
for field_name, field_type, *_ in field_infos:
121158
if field_type == CustomFieldTypeChoices.TYPE_OBJECT:
122159
total += dynamic_model.objects.filter(**{f"{field_name}_id": instance.pk}).count()
123160
elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
@@ -177,7 +214,7 @@ def get(self, request, pk, **kwargs):
177214

178215
# Build base queryset: union of all field filters for this type
179216
q_filter = Q()
180-
for field_name, field_type in field_infos:
217+
for field_name, field_type, *_ in field_infos:
181218
if field_type == CustomFieldTypeChoices.TYPE_OBJECT:
182219
q_filter |= Q(**{f"{field_name}_id": instance.pk})
183220
elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
@@ -213,6 +250,16 @@ def get(self, request, pk, **kwargs):
213250

214251
return_url = request.get_full_path()
215252

253+
# Add-button: link(s) to native CO add view with reverse field pre-filled
254+
add_permission = get_permission_for_model(dynamic_model, "add")
255+
can_add = request.user.has_perm(add_permission)
256+
add_links = _build_add_links(cot.slug, instance.pk, field_infos, return_url) if can_add else []
257+
258+
try:
259+
add_label = cot.get_verbose_name()
260+
except AttributeError:
261+
add_label = str(cot)
262+
216263
context = {
217264
"object": instance,
218265
"tab": self.tab,
@@ -223,6 +270,9 @@ def get(self, request, pk, **kwargs):
223270
"custom_object_type": cot,
224271
"model": dynamic_model,
225272
"preferences": preferences,
273+
"can_add": can_add,
274+
"add_links": add_links,
275+
"add_label": add_label,
226276
}
227277

228278
if request.htmx and not request.htmx.boosted:
@@ -250,16 +300,21 @@ def register_typed_tabs(model_classes, weight):
250300
).select_related("custom_object_type")
251301

252302
# Group by (content_type_id, custom_object_type_pk)
253-
# -> list of (field_name, field_type)
303+
# -> list of (field_name, field_type, field_label)
254304
ct_cot_fields = defaultdict(list)
255305
ct_cot_map = {} # (ct_id, cot_pk) -> CustomObjectType
256306
for field in all_fields:
257307
if field.related_object_type_id is None:
258308
continue
259309
key = (field.related_object_type_id, field.custom_object_type_id)
260-
ct_cot_fields[key].append((field.name, field.type))
310+
label = getattr(field, "label", "") or field.name
311+
ct_cot_fields[key].append((field.name, field.type, label))
261312
ct_cot_map[key] = field.custom_object_type
262313

314+
# Sort each group by field name for deterministic Add-button order
315+
for key in ct_cot_fields:
316+
ct_cot_fields[key].sort(key=lambda f: f[0])
317+
263318
# Build a set of content_type_ids we care about
264319
model_ct_map = {} # content_type_id -> model_class
265320
for model_class in model_classes:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "netbox-custom-objects-tab"
7-
version = "2.1.1"
7+
version = "2.2.0"
88
description = "NetBox plugin that adds a Custom Objects tab to object detail pages"
99
readme = "README.md"
1010
requires-python = ">=3.12"

tests/conftest.py

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
This file is loaded by pytest before collection, ensuring mocks exist before
55
plugin modules are imported.
66
"""
7+
78
import sys
89
from types import ModuleType
910
from unittest.mock import MagicMock
@@ -16,10 +17,10 @@ def _mock(dotted_name, **attrs):
1617
Create a mock module at `dotted_name` and register it (and any missing
1718
parent packages) in sys.modules. Does NOT overwrite already-present entries.
1819
"""
19-
parts = dotted_name.split('.')
20+
parts = dotted_name.split(".")
2021
# Ensure every parent package exists
2122
for i in range(1, len(parts)):
22-
parent = '.'.join(parts[:i])
23+
parent = ".".join(parts[:i])
2324
if parent not in sys.modules:
2425
sys.modules[parent] = ModuleType(parent)
2526

@@ -34,7 +35,7 @@ def _mock(dotted_name, **attrs):
3435

3536
# Attach as attribute on parent so `from parent import child` works
3637
if len(parts) > 1:
37-
parent_mod = sys.modules['.'.join(parts[:-1])]
38+
parent_mod = sys.modules[".".join(parts[:-1])]
3839
setattr(parent_mod, parts[-1], mod)
3940

4041
return mod
@@ -45,41 +46,43 @@ def _mock(dotted_name, **attrs):
4546
# comparisons inside views.py work correctly when we set field.type = TYPE_OBJECT.
4647
# ---------------------------------------------------------------------------
4748
class _CustomFieldTypeChoices:
48-
TYPE_OBJECT = 'object'
49-
TYPE_MULTIOBJECT = 'multiobject'
50-
TYPE_TEXT = 'text'
51-
TYPE_LONGTEXT = 'longtext'
49+
TYPE_OBJECT = "object"
50+
TYPE_MULTIOBJECT = "multiobject"
51+
TYPE_TEXT = "text"
52+
TYPE_LONGTEXT = "longtext"
5253

5354

5455
class _CustomFieldUIVisibleChoices:
55-
HIDDEN = 'hidden'
56+
HIDDEN = "hidden"
5657

5758

5859
# --- netbox.* ---
59-
_mock('netbox')
60-
_mock('netbox.registry', registry={"views": {}})
61-
_mock('netbox.plugins',
62-
PluginConfig=type('PluginConfig', (), {}),
63-
get_plugin_config=MagicMock(return_value=[]))
64-
_NetBoxModelFilterSetForm = type('NetBoxModelFilterSetForm', (), {})
65-
_mock('netbox.forms', NetBoxModelFilterSetForm=_NetBoxModelFilterSetForm)
66-
_mock('netbox.forms.mixins', SavedFiltersMixin=type('SavedFiltersMixin', (), {}))
60+
_mock("netbox")
61+
_mock("netbox.registry", registry={"views": {}})
62+
_mock("netbox.plugins", PluginConfig=type("PluginConfig", (), {}), get_plugin_config=MagicMock(return_value=[]))
63+
_NetBoxModelFilterSetForm = type("NetBoxModelFilterSetForm", (), {})
64+
_mock("netbox.forms", NetBoxModelFilterSetForm=_NetBoxModelFilterSetForm)
65+
_mock("netbox.forms.mixins", SavedFiltersMixin=type("SavedFiltersMixin", (), {}))
6766

6867
# --- extras.* ---
69-
_mock('extras')
68+
_mock("extras")
7069
_mock(
71-
'extras.choices',
70+
"extras.choices",
7271
CustomFieldTypeChoices=_CustomFieldTypeChoices,
7372
CustomFieldUIVisibleChoices=_CustomFieldUIVisibleChoices,
7473
)
7574

7675
# --- utilities.* ---
77-
_mock('utilities')
78-
_mock('utilities.views', ViewTab=MagicMock(), register_model_view=MagicMock())
79-
_mock('utilities.paginator', EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock())
80-
_mock('utilities.htmx', htmx_partial=MagicMock())
81-
_mock('utilities.forms')
82-
_mock('utilities.forms.fields', TagFilterField=MagicMock())
76+
_mock("utilities")
77+
_mock("utilities.views", ViewTab=MagicMock(), register_model_view=MagicMock())
78+
_mock("utilities.paginator", EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock())
79+
_mock("utilities.htmx", htmx_partial=MagicMock())
80+
_mock("utilities.forms")
81+
_mock("utilities.forms.fields", TagFilterField=MagicMock())
82+
_mock(
83+
"utilities.permissions",
84+
get_permission_for_model=MagicMock(return_value="netbox_custom_objects.add_customobject"),
85+
)
8386

8487

8588
class _FakeBaseTable(_tables2.Table):
@@ -119,12 +122,12 @@ def _set_columns(self, selected_columns):
119122
]
120123

121124

122-
_mock('netbox.tables', BaseTable=_FakeBaseTable)
125+
_mock("netbox.tables", BaseTable=_FakeBaseTable)
123126

124127
# --- netbox_custom_objects.* ---
125-
_mock('netbox_custom_objects')
126-
_mock('netbox_custom_objects.models', CustomObjectTypeField=MagicMock())
127-
_mock('netbox_custom_objects.field_types', FIELD_TYPE_CLASS={})
128-
_mock('netbox_custom_objects.filtersets', get_filterset_class=MagicMock())
129-
_CustomObjectTable = type('CustomObjectTable', (), {})
130-
_mock('netbox_custom_objects.tables', CustomObjectTable=_CustomObjectTable)
128+
_mock("netbox_custom_objects")
129+
_mock("netbox_custom_objects.models", CustomObjectTypeField=MagicMock())
130+
_mock("netbox_custom_objects.field_types", FIELD_TYPE_CLASS={})
131+
_mock("netbox_custom_objects.filtersets", get_filterset_class=MagicMock())
132+
_CustomObjectTable = type("CustomObjectTable", (), {})
133+
_mock("netbox_custom_objects.tables", CustomObjectTable=_CustomObjectTable)

tests/settings.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
# Only the packages needed to make ContentType importable are included.
33

44
INSTALLED_APPS = [
5-
'django.contrib.contenttypes',
6-
'django.contrib.auth',
5+
"django.contrib.contenttypes",
6+
"django.contrib.auth",
77
]
88

99
DATABASES = {
10-
'default': {
11-
'ENGINE': 'django.db.backends.sqlite3',
12-
'NAME': ':memory:',
10+
"default": {
11+
"ENGINE": "django.db.backends.sqlite3",
12+
"NAME": ":memory:",
1313
}
1414
}
1515

16-
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
16+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

0 commit comments

Comments
 (0)