Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.2.0] - 2026-04-22

### Added

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

## [2.1.0] - 2026-03-16

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@
{% load i18n %}
{% block content %}
{% if table %}
{# Add button (single) or split-dropdown (multiple back-reference fields) #}
{% if can_add and add_links %}
<div class="d-flex justify-content-end mt-2 mb-2">
{% if add_links|length == 1 %}
<a href="{{ add_links.0.url }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick"></i>
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
</a>
{% else %}
<div class="btn-group">
<button type="button"
class="btn btn-sm btn-primary dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="mdi mdi-plus-thick"></i>
{% blocktrans with label=add_label %}Add {{ label }}{% endblocktrans %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for link in add_links %}
<li>
<a class="dropdown-item" href="{{ link.url }}">{% blocktrans with f=link.label %}via {{ f }}{% endblocktrans %}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<hr class="mt-0 mb-3">
{# Results / Filters inner tabs #}
<ul class="nav nav-tabs custom-objects-subtabs mb-3" role="tablist">
Expand Down
65 changes: 60 additions & 5 deletions netbox_custom_objects_tab/views/typed.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
from collections import defaultdict
from urllib.parse import urlencode

from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.utils import OperationalError, ProgrammingError
from django.shortcuts import get_object_or_404, render
from django.urls import NoReverseMatch, reverse
from django.views.generic import View
from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices
from netbox.forms import NetBoxModelFilterSetForm
Expand All @@ -14,6 +16,7 @@
from netbox_custom_objects.models import CustomObjectTypeField
from netbox_custom_objects.tables import CustomObjectTable
from utilities.forms.fields import TagFilterField
from utilities.permissions import get_permission_for_model
from utilities.views import ViewTab, register_model_view

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


def _build_add_links(custom_object_type_slug, instance_pk, field_infos, return_url):
"""
Build pre-filled "Add" URLs for the native customobject_add view.

field_infos = list of (field_name, field_type, [label]) for fields referencing the parent.
Returns list of {"field_name", "label", "url"} dicts (one per unique field), or [] if URL
cannot be reversed (e.g. plugin URL conf not loaded).
"""
try:
add_base = reverse(
"plugins:netbox_custom_objects:customobject_add",
kwargs={"custom_object_type": custom_object_type_slug},
)
except NoReverseMatch:
return []

links = []
seen = set()
for field_name, _field_type, *rest in field_infos:
if field_name in seen:
continue
seen.add(field_name)
field_label = (rest[0] if rest else field_name) or field_name
qs = urlencode({field_name: instance_pk, "return_url": return_url})
links.append(
{
"field_name": field_name,
"label": field_label,
"url": f"{add_base}?{qs}",
}
)
return links


def _count_for_type(custom_object_type, field_infos):
"""
Return a badge callable for one Custom Object Type.
field_infos = list of (field_name, field_type) for fields referencing the parent model.
field_infos = list of (field_name, field_type, [label]) for fields referencing the parent model.
Uses COUNT(*) only. Returns None when 0.
"""

Expand All @@ -117,7 +154,7 @@ def _badge(instance):
return None

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

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

return_url = request.get_full_path()

# Add-button: link(s) to native CO add view with reverse field pre-filled
add_permission = get_permission_for_model(dynamic_model, "add")
can_add = request.user.has_perm(add_permission)
add_links = _build_add_links(cot.slug, instance.pk, field_infos, return_url) if can_add else []

try:
add_label = cot.get_verbose_name()
except AttributeError:
add_label = str(cot)

context = {
"object": instance,
"tab": self.tab,
Expand All @@ -223,6 +270,9 @@ def get(self, request, pk, **kwargs):
"custom_object_type": cot,
"model": dynamic_model,
"preferences": preferences,
"can_add": can_add,
"add_links": add_links,
"add_label": add_label,
}

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

# Group by (content_type_id, custom_object_type_pk)
# -> list of (field_name, field_type)
# -> list of (field_name, field_type, field_label)
ct_cot_fields = defaultdict(list)
ct_cot_map = {} # (ct_id, cot_pk) -> CustomObjectType
for field in all_fields:
if field.related_object_type_id is None:
continue
key = (field.related_object_type_id, field.custom_object_type_id)
ct_cot_fields[key].append((field.name, field.type))
label = getattr(field, "label", "") or field.name
ct_cot_fields[key].append((field.name, field.type, label))
ct_cot_map[key] = field.custom_object_type

# Sort each group by field name for deterministic Add-button order
for key in ct_cot_fields:
ct_cot_fields[key].sort(key=lambda f: f[0])

# Build a set of content_type_ids we care about
model_ct_map = {} # content_type_id -> model_class
for model_class in model_classes:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "netbox-custom-objects-tab"
version = "2.1.1"
version = "2.2.0"
description = "NetBox plugin that adds a Custom Objects tab to object detail pages"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
65 changes: 34 additions & 31 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This file is loaded by pytest before collection, ensuring mocks exist before
plugin modules are imported.
"""

import sys
from types import ModuleType
from unittest.mock import MagicMock
Expand All @@ -16,10 +17,10 @@ def _mock(dotted_name, **attrs):
Create a mock module at `dotted_name` and register it (and any missing
parent packages) in sys.modules. Does NOT overwrite already-present entries.
"""
parts = dotted_name.split('.')
parts = dotted_name.split(".")
# Ensure every parent package exists
for i in range(1, len(parts)):
parent = '.'.join(parts[:i])
parent = ".".join(parts[:i])
if parent not in sys.modules:
sys.modules[parent] = ModuleType(parent)

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

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

return mod
Expand All @@ -45,41 +46,43 @@ def _mock(dotted_name, **attrs):
# comparisons inside views.py work correctly when we set field.type = TYPE_OBJECT.
# ---------------------------------------------------------------------------
class _CustomFieldTypeChoices:
TYPE_OBJECT = 'object'
TYPE_MULTIOBJECT = 'multiobject'
TYPE_TEXT = 'text'
TYPE_LONGTEXT = 'longtext'
TYPE_OBJECT = "object"
TYPE_MULTIOBJECT = "multiobject"
TYPE_TEXT = "text"
TYPE_LONGTEXT = "longtext"


class _CustomFieldUIVisibleChoices:
HIDDEN = 'hidden'
HIDDEN = "hidden"


# --- netbox.* ---
_mock('netbox')
_mock('netbox.registry', registry={"views": {}})
_mock('netbox.plugins',
PluginConfig=type('PluginConfig', (), {}),
get_plugin_config=MagicMock(return_value=[]))
_NetBoxModelFilterSetForm = type('NetBoxModelFilterSetForm', (), {})
_mock('netbox.forms', NetBoxModelFilterSetForm=_NetBoxModelFilterSetForm)
_mock('netbox.forms.mixins', SavedFiltersMixin=type('SavedFiltersMixin', (), {}))
_mock("netbox")
_mock("netbox.registry", registry={"views": {}})
_mock("netbox.plugins", PluginConfig=type("PluginConfig", (), {}), get_plugin_config=MagicMock(return_value=[]))
_NetBoxModelFilterSetForm = type("NetBoxModelFilterSetForm", (), {})
_mock("netbox.forms", NetBoxModelFilterSetForm=_NetBoxModelFilterSetForm)
_mock("netbox.forms.mixins", SavedFiltersMixin=type("SavedFiltersMixin", (), {}))

# --- extras.* ---
_mock('extras')
_mock("extras")
_mock(
'extras.choices',
"extras.choices",
CustomFieldTypeChoices=_CustomFieldTypeChoices,
CustomFieldUIVisibleChoices=_CustomFieldUIVisibleChoices,
)

# --- utilities.* ---
_mock('utilities')
_mock('utilities.views', ViewTab=MagicMock(), register_model_view=MagicMock())
_mock('utilities.paginator', EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock())
_mock('utilities.htmx', htmx_partial=MagicMock())
_mock('utilities.forms')
_mock('utilities.forms.fields', TagFilterField=MagicMock())
_mock("utilities")
_mock("utilities.views", ViewTab=MagicMock(), register_model_view=MagicMock())
_mock("utilities.paginator", EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock())
_mock("utilities.htmx", htmx_partial=MagicMock())
_mock("utilities.forms")
_mock("utilities.forms.fields", TagFilterField=MagicMock())
_mock(
"utilities.permissions",
get_permission_for_model=MagicMock(return_value="netbox_custom_objects.add_customobject"),
)


class _FakeBaseTable(_tables2.Table):
Expand Down Expand Up @@ -119,12 +122,12 @@ def _set_columns(self, selected_columns):
]


_mock('netbox.tables', BaseTable=_FakeBaseTable)
_mock("netbox.tables", BaseTable=_FakeBaseTable)

# --- netbox_custom_objects.* ---
_mock('netbox_custom_objects')
_mock('netbox_custom_objects.models', CustomObjectTypeField=MagicMock())
_mock('netbox_custom_objects.field_types', FIELD_TYPE_CLASS={})
_mock('netbox_custom_objects.filtersets', get_filterset_class=MagicMock())
_CustomObjectTable = type('CustomObjectTable', (), {})
_mock('netbox_custom_objects.tables', CustomObjectTable=_CustomObjectTable)
_mock("netbox_custom_objects")
_mock("netbox_custom_objects.models", CustomObjectTypeField=MagicMock())
_mock("netbox_custom_objects.field_types", FIELD_TYPE_CLASS={})
_mock("netbox_custom_objects.filtersets", get_filterset_class=MagicMock())
_CustomObjectTable = type("CustomObjectTable", (), {})
_mock("netbox_custom_objects.tables", CustomObjectTable=_CustomObjectTable)
12 changes: 6 additions & 6 deletions tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
# Only the packages needed to make ContentType importable are included.

INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.auth',
"django.contrib.contenttypes",
"django.contrib.auth",
]

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
Loading
Loading