Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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).

## [Unreleased]

### Changed

- Internal simplification (no behaviour change): shared `_host_q()` for the
badge and view querysets, single error-render path, `field_info` tuples
unpacked directly (always 5-tuples), unreachable import fallbacks and the
redundant field de-dup set removed, bulk-action buttons moved to one
`typed/bulk_buttons.html` include, duplicate `{% csrf_token %}` dropped,
`MANIFEST.in` removed (package-data already ships templates).

## [3.0.0] - 2026-09-16

### Removed
Expand Down
1 change: 0 additions & 1 deletion MANIFEST.in

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% if can_change or can_delete %}
<div class="bulk-action-buttons">
{% if can_change %}
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
{% endif %}
{% if can_delete %}
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
{% endif %}
</div>
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -66,32 +66,12 @@
{% endblocktrans %}
</label>
</div>
{% if can_change or can_delete %}
<div class="bulk-action-buttons">
{% if can_change %}
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
{% endif %}
{% if can_delete %}
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
{% endif %}
</div>
{% endif %}
{% include "netbox_custom_objects_tab/typed/bulk_buttons.html" %}
</div>
</div>
</div>
{% endif %}
<div class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{{ return_url }}" />
{# Objects table #}
<div class="card">
Expand Down Expand Up @@ -129,26 +109,7 @@
</div>
{% endif %}
{% endif %}
{% if can_change or can_delete %}
<div class="bulk-action-buttons">
{% if can_change %}
<button type="submit"
name="_edit"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_edit' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Bulk Edit
</button>
{% endif %}
{% if can_delete %}
<button type="submit"
name="_delete"
formaction="{% url 'plugins:netbox_custom_objects:customobject_bulk_delete' custom_object_type=custom_object_type.slug %}?return_url={{ return_url|urlencode:'' }}"
class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Bulk Delete
</button>
{% endif %}
</div>
{% endif %}
{% include "netbox_custom_objects_tab/typed/bulk_buttons.html" %}
</div>
</div>
</form>
Expand Down
23 changes: 5 additions & 18 deletions netbox_custom_objects_tab/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,10 @@ def _resolve_dynamic_custom_object_models():

A restart is required whenever a new Custom Object Type is added.
"""
try:
from netbox_custom_objects.models import CustomObject
except ImportError:
logger.warning("netbox_custom_objects plugin not installed — skipping")
return []

try:
app_config = apps.get_app_config(_CUSTOM_OBJECTS_APP)
except LookupError:
logger.warning("netbox_custom_objects app not found — skipping")
return []
from netbox_custom_objects.models import CustomObject

# Filter to dynamic CO models only (subclasses of CustomObject, not CustomObject itself).
app_config = apps.get_app_config(_CUSTOM_OBJECTS_APP)
return [m for m in app_config.get_models() if issubclass(m, CustomObject) and m is not CustomObject]


Expand Down Expand Up @@ -110,7 +101,6 @@ def dispatch(request, custom_object_type, pk, **kwargs):
raise Http404(f"No '{action_name}' tab registered for {custom_object_type}")
return view_cls.as_view()(request, custom_object_type=custom_object_type, pk=pk, **kwargs)

dispatch.__name__ = f"{action_name}_co_dispatch"
return dispatch


Expand All @@ -128,12 +118,9 @@ def _inject_co_urls():
``plugins:netbox_custom_objects:customobject_{action}``
so each typed tab gets ``customobject_custom_objects_{slug}``.
"""
try:
import netbox_custom_objects.urls as co_urls
from django.urls import path as url_path
from netbox.registry import registry
except ImportError:
return
import netbox_custom_objects.urls as co_urls
from django.urls import path as url_path
from netbox.registry import registry

# Action names of the typed-tab views we registered on CO dynamic models.
action_names = set()
Expand Down
110 changes: 41 additions & 69 deletions netbox_custom_objects_tab/views/typed.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,7 @@ def _build_q_for_field(host_ct_id, instance_pk, field_info):
last two are only meaningful for polymorphic fields. Returns Q() (an empty
no-op filter) if the field can't be resolved, so callers can OR it safely.
"""
field_name = field_info[0]
field_type = field_info[1]
is_poly = field_info[3] if len(field_info) >= 5 else False
through_model_name = field_info[4] if len(field_info) >= 5 else None
field_name, field_type, _label, is_poly, through_model_name = field_info

if field_type == CustomFieldTypeChoices.TYPE_OBJECT:
if is_poly:
Expand Down Expand Up @@ -168,15 +165,11 @@ def _build_add_links(custom_object_type_slug, host_instance, field_infos, return

links = []
seen = set()
for field_info in field_infos:
field_name = field_info[0]
for field_name, field_type, field_label, is_poly, _through in field_infos:
if field_name in seen:
continue
seen.add(field_name)

field_type = field_info[1]
field_label = (field_info[2] if len(field_info) >= 3 and field_info[2] else field_name) or field_name
is_poly = len(field_info) >= 5 and field_info[3]
field_label = field_label or field_name

if not is_poly:
prefill = {field_name: host_pk}
Expand All @@ -198,6 +191,19 @@ def _build_add_links(custom_object_type_slug, host_instance, field_infos, return
return links


def _host_q(host_ct_id, instance_pk, field_infos):
"""OR of every field's Q for this host; None when no field could be resolved
(an empty Q() would match ALL rows, so callers must not filter on it)."""
q_filter = Q()
has_filter = False
for info in field_infos:
q = _build_q_for_field(host_ct_id, instance_pk, info)
if q.children:
q_filter |= q
has_filter = True
return q_filter if has_filter else None


def _count_for_type(custom_object_type, field_infos, host_ct_id):
"""
Return a badge callable for one Custom Object Type.
Expand Down Expand Up @@ -226,15 +232,8 @@ def _badge(instance):
)
return None

q_filter = Q()
has_filter = False
for info in field_infos:
q = _build_q_for_field(host_ct_id, instance.pk, info)
if q.children:
q_filter |= q
has_filter = True

if not has_filter:
q_filter = _host_q(host_ct_id, instance.pk, field_infos)
if q_filter is None:
return None

total = dynamic_model.objects.filter(q_filter).distinct().count()
Expand Down Expand Up @@ -274,45 +273,31 @@ def get(self, request, pk, **kwargs):
# Re-fetch CustomObjectType at request time (may have changed since ready())
from netbox_custom_objects.models import CustomObjectType as COTModel

error_context = {
"object": instance,
"tab": self.tab,
"base_template": _get_base_template(instance),
"table": None,
"preferences": {"pagination.placement": "bottom"},
}
try:
cot = COTModel.objects.get(pk=cot_pk)
except COTModel.DoesNotExist:
return render(request, "netbox_custom_objects_tab/typed/tab.html", error_context)

cot = COTModel.objects.filter(pk=cot_pk).first()
try:
dynamic_model = cot.get_model()
dynamic_model = cot.get_model() if cot else None
except Exception:
logger.exception("Could not get model for CustomObjectType %s", cot_pk)
return render(request, "netbox_custom_objects_tab/typed/tab.html", error_context)

# Build base queryset: union of all field filters for this type.
# Polymorphic fields contribute Q(pk__in=<through subquery>) or a
# (content_type_id, object_id) pair, both handled by _build_q_for_field.
#
# An empty Q() is the identity element of `|`, so filter(Q()) returns
# ALL rows. Track has_filter (mirrors _count_for_type) and short-circuit
# to .none() if every _build_q_for_field call returned an empty Q —
# otherwise an unresolvable through model or unknown field type would
# silently widen the tab to every row of the target type.
q_filter = Q()
has_filter = False
for info in field_infos:
q = _build_q_for_field(host_ct_id, instance.pk, info)
if q.children:
q_filter |= q
has_filter = True

if has_filter:
base_qs = dynamic_model.objects.filter(q_filter).distinct()
else:
dynamic_model = None
if dynamic_model is None:
return render(
request,
"netbox_custom_objects_tab/typed/tab.html",
{
"object": instance,
"tab": self.tab,
"base_template": _get_base_template(instance),
"table": None,
"preferences": {"pagination.placement": "bottom"},
},
)

# Base queryset: OR of all field filters for this type (see _host_q).
q_filter = _host_q(host_ct_id, instance.pk, field_infos)
if q_filter is None:
base_qs = dynamic_model.objects.none()
else:
base_qs = dynamic_model.objects.filter(q_filter).distinct()

# Apply filterset
filterset_class = get_filterset_class(dynamic_model)
Expand Down Expand Up @@ -361,10 +346,7 @@ def get(self, request, pk, **kwargs):
# Documented in README "Known Issues" and CHANGELOG [2.3.0].
add_links = _build_add_links(cot.slug, instance, field_infos, return_url) if can_add else []

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

context = {
"object": instance,
Expand All @@ -388,7 +370,6 @@ def get(self, request, pk, **kwargs):
return render(request, "netbox_custom_objects_tab/typed/tab.html", context)

_TypedTabView.__name__ = f"{model_class.__name__}_{custom_object_type.slug}_TypedTabView"
_TypedTabView.__qualname__ = f"{model_class.__name__}_{custom_object_type.slug}_TypedTabView"
return _TypedTabView


Expand All @@ -405,11 +386,7 @@ def register_typed_tabs(model_classes, weight):
]

# Non-polymorphic fields: single related_object_type FK.
# is_polymorphic=False keeps this queryset disjoint from poly_fields
# below — a field row with both attrs set (legacy misconfig:
# is_polymorphic is immutable upstream but related_object_type isn't
# nulled when toggled) would otherwise hit both querysets. _record's
# seen_field_keys stays as defence in depth.
# is_polymorphic=False keeps this queryset disjoint from poly_fields below.
non_poly_fields = list(
CustomObjectTypeField.objects.filter(
is_polymorphic=False,
Expand All @@ -433,15 +410,10 @@ def register_typed_tabs(model_classes, weight):
# -> list of (name, type, label, is_polymorphic, through_model_name)
ct_cot_fields = defaultdict(list)
ct_cot_map = {} # (ct_id, cot_pk) -> CustomObjectType
seen_field_keys = set() # (field.pk, ct_id) — de-dup if a field appears in both querysets

def _record(field, ct_id, is_poly):
if ct_id is None:
return
key_dup = (field.pk, ct_id)
if key_dup in seen_field_keys:
return
seen_field_keys.add(key_dup)
key = (ct_id, field.custom_object_type_id)
label = getattr(field, "label", "") or field.name
through_name = field.through_model_name if is_poly else None
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ where = ["."]
include = ["netbox_custom_objects_tab*"]

[tool.setuptools.package-data]
netbox_custom_objects_tab = ["**/*", "templates/**"]
netbox_custom_objects_tab = ["**/*"]

[tool.pytest.ini_options]
pythonpath = ["."]
Expand Down
Loading
Loading