Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release v1.8.0 #766

Merged
merged 52 commits into from
Jan 3, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
a0eff04
Post-release version bump
jeremystretch Dec 8, 2016
bd40f72
#49: Allow selection of devices at other sites when connecting an int…
jeremystretch Dec 9, 2016
298ac1b
Widened page layout; improved mobile rendering
jeremystretch Dec 9, 2016
bf817eb
Closes #49: Introduction of circuit terminations
jeremystretch Dec 14, 2016
6a9f26a
Cleaned up attribute tables
jeremystretch Dec 14, 2016
66fa877
ObjectEditView: Save many-to-many fields
jeremystretch Dec 15, 2016
f02c222
Closes #539: Implemented L4 services for devices
jeremystretch Dec 15, 2016
017263f
Fixes #741: Hide "select all" button for users without edit permissions
jeremystretch Dec 15, 2016
712567c
Closes #613: Added prefixes column to VLAN list; added VLAN column to…
jeremystretch Dec 15, 2016
b56e37a
Closes #722: Enabled custom fields for device types
jeremystretch Dec 16, 2016
b451ece
Closes #122: Add comments field to device types
jeremystretch Dec 16, 2016
b7fe220
Converted module_add to ObjectEditView
jeremystretch Dec 16, 2016
6f1532a
Fixed dcim tests
jeremystretch Dec 16, 2016
c94d111
Closes #743: Enabled bulk creation of all device components
jeremystretch Dec 16, 2016
15bec75
Fixes #744: Fixed export of sites without an AS number
jeremystretch Dec 16, 2016
550efcb
Tweaked prefix column header padding
jeremystretch Dec 16, 2016
44d5ff2
Fixes #747: Fixes natural_order_by integer cast error on large numbers
jeremystretch Dec 19, 2016
f0d8e02
Fixed prefix/VLAN role links
jeremystretch Dec 19, 2016
9fd9719
Closes #181: Implemented support for bulk IP address creation
jeremystretch Dec 20, 2016
96de61d
Closes #716: Add ASN field to site bulk edit form
jeremystretch Dec 20, 2016
ae8f40e
Fixes #563: Allow a device to be flipped from one rack face to the other
jeremystretch Dec 21, 2016
b6da5ce
Fixed device type component creation permissions
jeremystretch Dec 21, 2016
1ed5389
Fixed device component bulk creation permissions
jeremystretch Dec 21, 2016
37b2ff0
Standardized inheritance order of BootstrapMixin
jeremystretch Dec 21, 2016
7b06f5e
Introduced DeviceComponentCreateView
jeremystretch Dec 21, 2016
0e4d02b
Renamed template
jeremystretch Dec 21, 2016
3de5187
Refactored device component creation views
jeremystretch Dec 21, 2016
c1b6da7
Only display "select all" button if there are two or more items
jeremystretch Dec 21, 2016
e868424
Added permissions evaluation
jeremystretch Dec 21, 2016
0ac3e91
Updated middleware for Django 1.10
jeremystretch Dec 26, 2016
cf796fb
Fixes #751: Relax version constraint on python-cryptography
jeremystretch Dec 26, 2016
65d8bb8
Bumped Markdown version
jeremystretch Dec 26, 2016
a5fe446
Upgraded django-filter to 0.15.3
jeremystretch Dec 26, 2016
edb8904
Fixed debug toolbar display
jeremystretch Dec 26, 2016
04fd197
Fixed table form rendering for django-tables2>=1.2.1
jeremystretch Dec 26, 2016
1882d83
Bumped django-tables2 version
jeremystretch Dec 26, 2016
9e670d3
Relaxed version requirements
jeremystretch Dec 26, 2016
bdff71d
Fixed paginator text rendering
jeremystretch Dec 26, 2016
5716207
Simplified paginator when dealing with <=5 pages
jeremystretch Dec 26, 2016
e647065
Improved device interface list performance
jeremystretch Dec 27, 2016
d9d7068
Fixed bug introduced in 04fd197c9b752b7f0c3efef48e6c0f51e24714f6
jeremystretch Dec 27, 2016
8edaff8
Fixes #658: Added is_pool field to Prefix model
jeremystretch Dec 27, 2016
e7b08f8
Closes #756: Added contact details to site model
jeremystretch Dec 29, 2016
e06bfff
Fixed outdated select_related reference to circuit
jeremystretch Dec 29, 2016
48e9cd6
Miscellaneous cleanup and documentation
jeremystretch Dec 29, 2016
5215779
Fixes #757: Debug toolbar middleware must always be included, even if…
jeremystretch Dec 29, 2016
49dd576
Tweaked web server installation docs
jeremystretch Jan 3, 2017
050b644
Split site contact info into a separate panel
jeremystretch Jan 3, 2017
31e8986
Minor docs tweaks
jeremystretch Jan 3, 2017
c7acc9a
Updated circuit import template
jeremystretch Jan 3, 2017
cf64ef3
Fixes #763: Added missing fields to CSV exports
jeremystretch Jan 3, 2017
f8bced3
Release v1.8.0
jeremystretch Jan 3, 2017
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
6 changes: 6 additions & 0 deletions docs/data-model/ipam.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.

Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.

---

# Services

A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.)
2 changes: 1 addition & 1 deletion docs/installation/netbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```

Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use.
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**

!!! warning
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.
4 changes: 2 additions & 2 deletions docs/installation/postgresql.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)

# Installation

Expand All @@ -15,7 +15,7 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as
# postgresql-setup initdb
```

If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:

```no-highlight
host all all 127.0.0.1/32 md5
Expand Down
4 changes: 2 additions & 2 deletions docs/installation/web-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https

# gunicorn Installation

Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`.
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.

```no-highlight
command = '/usr/bin/gunicorn'
Expand All @@ -113,7 +113,7 @@ user = 'www-data'

# supervisord Installation

Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed.
Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.

```no-highlight
[program:netbox]
Expand Down
6 changes: 2 additions & 4 deletions netbox/circuits/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin):

@admin.register(Circuit)
class CircuitAdmin(admin.ModelAdmin):
list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
list_filter = ['provider', 'type', 'tenant']
exclude = ['interface']

def get_queryset(self, request):
qs = super(CircuitAdmin, self).get_queryset(request)
return qs.select_related('provider', 'type', 'tenant', 'site')
return qs.select_related('provider', 'type', 'tenant')
17 changes: 12 additions & 5 deletions netbox/circuits/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers

from circuits.models import Provider, CircuitType, Circuit
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from extras.api.serializers import CustomFieldSerializer
from tenancy.api.serializers import TenantNestedSerializer
Expand Down Expand Up @@ -45,17 +45,24 @@ class Meta(CircuitTypeSerializer.Meta):
# Circuits
#

class CircuitTerminationSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
interface = InterfaceNestedSerializer()

class Meta:
model = CircuitTermination
fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']


class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
provider = ProviderNestedSerializer()
type = CircuitTypeNestedSerializer()
tenant = TenantNestedSerializer()
site = SiteNestedSerializer()
interface = InterfaceNestedSerializer()
terminations = CircuitTerminationSerializer(many=True)

class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']


class CircuitNestedSerializer(CircuitSerializer):
Expand Down
4 changes: 2 additions & 2 deletions netbox/circuits/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter
Expand All @@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single circuit
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer
17 changes: 8 additions & 9 deletions netbox/circuits/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
name='circuits__terminations__site',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='circuits__site',
name='circuits__terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)

class Meta:
model = Provider
fields = ['q', 'name', 'account', 'asn']
fields = ['name', 'account', 'asn']

def search(self, queryset, value):
return queryset.filter(
Expand All @@ -50,7 +50,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Provider (ID)',
)
provider = django_filters.ModelMultipleChoiceFilter(
name='provider',
name='provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
label='Provider (slug)',
Expand All @@ -61,7 +61,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Circuit type (ID)',
)
type = django_filters.ModelMultipleChoiceFilter(
name='type',
name='type__slug',
queryset=CircuitType.objects.all(),
to_field_name='slug',
label='Circuit type (slug)',
Expand All @@ -78,25 +78,24 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
name='terminations__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
name='terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)

class Meta:
model = Circuit
fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
fields = ['install_date']

def search(self, queryset, value):
return queryset.filter(
Q(cid__icontains=value) |
Q(xconnect_id__icontains=value) |
Q(pp_info__icontains=value) |
Q(comments__icontains=value)
)
133 changes: 73 additions & 60 deletions netbox/circuits/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
SlugField,
)

from .models import Circuit, CircuitType, Provider
from .models import Circuit, CircuitTermination, CircuitType, Provider


#
Expand Down Expand Up @@ -43,7 +43,7 @@ class Meta:
fields = ['name', 'slug', 'asn', 'account', 'portal_url']


class ProviderImportForm(BulkImportForm, BootstrapMixin):
class ProviderImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=ProviderFromCSVForm)


Expand All @@ -69,7 +69,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Circuit types
#

class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()

class Meta:
Expand All @@ -82,6 +82,64 @@ class Meta:
#

class CircuitForm(BootstrapMixin, CustomFieldForm):
comments = CommentField()

class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'commit_rate': "Committed rate",
}


class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})

class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']


class CircuitImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=CircuitFromCSVForm)


class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField(widget=SmallTextarea)

class Meta:
nullable_fields = ['tenant', 'commit_rate', 'comments']


class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug')


#
# Circuit terminations
#

class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
Expand All @@ -95,28 +153,25 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
comments = CommentField()

class Meta:
model = Circuit
fields = [
'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
]
model = CircuitTermination
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info']
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'port_speed': "Physical circuit speed",
'commit_rate': "Commited rate",
'xconnect_id': "ID of the local cross-connect",
'pp_info': "Patch panel ID and port number(s)"
}
widgets = {
'term_side': forms.HiddenInput(),
}

def __init__(self, *args, **kwargs):

super(CircuitForm, self).__init__(*args, **kwargs)
super(CircuitTerminationForm, self).__init__(*args, **kwargs)

# If this circuit has been assigned to an interface, initialize rack and device
# If an interface has been assigned, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
Expand All @@ -140,11 +195,13 @@ def __init__(self, *args, **kwargs):
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
.exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
Expand All @@ -154,47 +211,3 @@ def __init__(self, *args, **kwargs):
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
]


class CircuitFromCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Provider not found.'})
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})

class Meta:
model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
'commit_rate', 'xconnect_id', 'pp_info']


class CircuitImportForm(BulkImportForm, BootstrapMixin):
csv = CSVDataField(csv_form=CircuitFromCSVForm)


class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
comments = CommentField(widget=SmallTextarea)

class Meta:
nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']


class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')
Loading