Contact Group "Contacts" count is wrong in case of multi-assignment to nested groups #267

Open
opened 2026-04-05 16:24:01 +02:00 by MrUnknownDE · 0 comments
Owner

Originally created by @robinchrist on 2/28/2026

NetBox Edition

NetBox Community

NetBox Version

v4.5.3

Python Version

3.12

Steps to Reproduce

While working on our Netbox fork and modifying Tenant Groups to work similar to Contact Groups (we want Tenant<->Tenant Group to be a ManyToMany relation), I noticed that Contact Groups have some issues that we solved for our Tenant Groups.

Take the following example data (adjusted from the example dataset):

  • Add a new subgroup testgroup2 to Customers
  • Add group assignment to testgroup2 to Dwight Schrute
Image

Notice that the count, 4, is wrong:
Image

Also on the detail page:

Image

Expected Behavior

The count should take the "duplicated" elements (due to multi assignment into into nested groups) into account.

Observed Behavior

See above.

Proposed Fix

For reference, I'm attaching how we solved it:

For the "related objects":

return {
            'related_models': self.get_related_models(
                request,
                groups,
                extra=[
                    (
                        BillableTenant.objects.restrict(request.user, 'view').filter(groups__in=groups).distinct(),
                        'group_id',
                    ),
                ],
            ),
        }

Note the distinct(), which fixes the count on the detail page under related objects.

For the list view / table, we added a new method add_related_count_distinct, we basically forked MPTT's add_related_count:

class SQCountDistinct(Subquery):
    """
    Like mptt's SQCount, but uses COUNT(DISTINCT pk) instead of COUNT(*) to avoid
    double-counting related objects that match multiple nodes in the tree range
    (e.g. a tenant that is a member of both a parent group and a child group).
    """
    template = '(SELECT count(DISTINCT pk) FROM (%(subquery)s) _count)'
    output_field = IntegerField()


def add_related_count_distinct(
        self,
        queryset,
        rel_model,
        rel_field,
        count_attr,
        cumulative=False,
        extra_filters=None,
    ):
        """
        Like add_related_count(), but deduplicates related objects by pk so that
        objects belonging to multiple nodes within the same tree range are only
        counted once.

        This is necessary when rel_model has a ManyToMany relation to the tree
        model and an object can be a member of both a parent and a child node —
        the standard add_related_count() would count such objects multiple times.

        Arguments are identical to add_related_count().
        """
        if extra_filters is None:
            extra_filters = {}
        if cumulative:
            subquery_filters = {
                rel_field + '__tree_id': OuterRef(self.tree_id_attr),
                rel_field + '__lft__gte': OuterRef(self.left_attr),
                rel_field + '__lft__lte': OuterRef(self.right_attr),
            }
        else:
            current_rel_model = rel_model
            for rel_field_part in rel_field.split('__'):
                current_mptt_field = current_rel_model._meta.get_field(rel_field_part)
                current_rel_model = current_mptt_field.related_model
            mptt_field = current_mptt_field

            if isinstance(mptt_field, ManyToManyField):
                field_name = 'pk'
            else:
                field_name = mptt_field.remote_field.field_name

            subquery_filters = {
                rel_field: OuterRef(field_name),
            }
        subquery = rel_model.objects.filter(**subquery_filters, **extra_filters).values('pk')
        return queryset.annotate(**{count_attr: SQCountDistinct(subquery)})
*Originally created by @robinchrist on 2/28/2026* ### NetBox Edition NetBox Community ### NetBox Version v4.5.3 ### Python Version 3.12 ### Steps to Reproduce While working on our Netbox fork and modifying Tenant Groups to work similar to Contact Groups (we want Tenant<->Tenant Group to be a ManyToMany relation), I noticed that Contact Groups have some issues that we solved for our Tenant Groups. Take the following example data (adjusted from the example dataset): - Add a new subgroup `testgroup2` to `Customers` - Add group assignment to `testgroup2` to `Dwight Schrute` <img width="1132" height="607" alt="Image" src="https://github.com/user-attachments/assets/8bf42ffa-5f77-4880-9169-c1ffb008f94b" /> Notice that the count, 4, is wrong: <img width="1132" height="607" alt="Image" src="https://github.com/user-attachments/assets/c3d1cdf8-9a06-4d4f-8a6a-49791948e466" /> Also on the detail page: <img width="1132" height="607" alt="Image" src="https://github.com/user-attachments/assets/a07bbb3d-a6dd-48ab-b2ac-a734e6340ec5" /> ### Expected Behavior The count should take the "duplicated" elements (due to multi assignment into into nested groups) into account. ### Observed Behavior See above. ### Proposed Fix For reference, I'm attaching how we solved it: For the "related objects": ``` return { 'related_models': self.get_related_models( request, groups, extra=[ ( BillableTenant.objects.restrict(request.user, 'view').filter(groups__in=groups).distinct(), 'group_id', ), ], ), } ``` Note the `distinct()`, which fixes the count on the detail page under `related objects`. For the list view / table, we added a new method `add_related_count_distinct`, we basically forked MPTT's `add_related_count`: ``` class SQCountDistinct(Subquery): """ Like mptt's SQCount, but uses COUNT(DISTINCT pk) instead of COUNT(*) to avoid double-counting related objects that match multiple nodes in the tree range (e.g. a tenant that is a member of both a parent group and a child group). """ template = '(SELECT count(DISTINCT pk) FROM (%(subquery)s) _count)' output_field = IntegerField() def add_related_count_distinct( self, queryset, rel_model, rel_field, count_attr, cumulative=False, extra_filters=None, ): """ Like add_related_count(), but deduplicates related objects by pk so that objects belonging to multiple nodes within the same tree range are only counted once. This is necessary when rel_model has a ManyToMany relation to the tree model and an object can be a member of both a parent and a child node — the standard add_related_count() would count such objects multiple times. Arguments are identical to add_related_count(). """ if extra_filters is None: extra_filters = {} if cumulative: subquery_filters = { rel_field + '__tree_id': OuterRef(self.tree_id_attr), rel_field + '__lft__gte': OuterRef(self.left_attr), rel_field + '__lft__lte': OuterRef(self.right_attr), } else: current_rel_model = rel_model for rel_field_part in rel_field.split('__'): current_mptt_field = current_rel_model._meta.get_field(rel_field_part) current_rel_model = current_mptt_field.related_model mptt_field = current_mptt_field if isinstance(mptt_field, ManyToManyField): field_name = 'pk' else: field_name = mptt_field.remote_field.field_name subquery_filters = { rel_field: OuterRef(field_name), } subquery = rel_model.objects.filter(**subquery_filters, **extra_filters).values('pk') return queryset.annotate(**{count_attr: SQCountDistinct(subquery)}) ```
MrUnknownDE added the type: bugnetboxtype: bugtype: bugtype: bugseverity: lowstatus: acceptedtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugtype: bugnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxnetboxseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowseverity: lowstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: acceptedstatus: accepted labels 2026-04-05 16:24:04 +02:00
Sign in to join this conversation.
No Label netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox netbox severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low severity: low status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted status: accepted type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug type: bug
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github/netbox#267