Browse Source

Merge branch 'develop' into develop-2.3

Jeremy Stretch 7 years ago
parent
commit
2fc1519bc6

+ 1 - 1
docs/api/examples.md

@@ -123,7 +123,7 @@ $ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc
 Send an authenticated `DELETE` request to the site detail endpoint.
 
 ```
-$ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
+$ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
 * Connected to localhost (127.0.0.1) port 8000 (#0)
 > DELETE /api/dcim/sites/16/ HTTP/1.1
 > User-Agent: curl/7.35.0

+ 12 - 11
netbox/dcim/models.py

@@ -143,6 +143,11 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     def count_circuits(self):
         return Circuit.objects.filter(terminations__site=self).count()
 
+    @property
+    def count_vms(self):
+        from virtualization.models import VirtualMachine
+        return VirtualMachine.objects.filter(cluster__site=self).count()
+
 
 #
 # Racks
@@ -1090,16 +1095,11 @@ class ConsolePort(models.Model):
 class ConsoleServerPortManager(models.Manager):
 
     def get_queryset(self):
-        """
-        Include the trailing numeric portion of each port name to allow for proper ordering.
-        For example:
-            Port 1, Port 2, Port 3 ... Port 9, Port 10, Port 11 ...
-        Instead of:
-            Port 1, Port 10, Port 11 ... Port 19, Port 2, Port 20 ...
-        """
+        # Pad any trailing digits to effect natural sorting
         return super(ConsoleServerPortManager, self).get_queryset().extra(select={
-            'name_as_integer': "CAST(substring(dcim_consoleserverport.name FROM '[0-9]+$') AS INTEGER)",
-        }).order_by('device', 'name_as_integer')
+            'name_padded': "CONCAT(REGEXP_REPLACE(dcim_consoleserverport.name, '\d+$', ''), "
+                           "LPAD(SUBSTRING(dcim_consoleserverport.name FROM '\d+$'), 8, '0'))",
+        }).order_by('device', 'name_padded')
 
 
 @python_2_unicode_compatible
@@ -1172,9 +1172,10 @@ class PowerPort(models.Model):
 class PowerOutletManager(models.Manager):
 
     def get_queryset(self):
+        # Pad any trailing digits to effect natural sorting
         return super(PowerOutletManager, self).get_queryset().extra(select={
-            'name_padded': "CONCAT(SUBSTRING(dcim_poweroutlet.name FROM '^[^0-9]+'), "
-                           "LPAD(SUBSTRING(dcim_poweroutlet.name FROM '[0-9\/]+$'), 8, '0'))",
+            'name_padded': "CONCAT(REGEXP_REPLACE(dcim_poweroutlet.name, '\d+$', ''), "
+                           "LPAD(SUBSTRING(dcim_poweroutlet.name FROM '\d+$'), 8, '0'))",
         }).order_by('device', 'name_padded')
 
 

+ 2 - 1
netbox/dcim/tables.py

@@ -153,11 +153,12 @@ class SiteDetailTable(SiteTable):
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
     vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
+    vm_count = tables.Column(accessor=Accessor('count_vms'), orderable=False, verbose_name='VMs')
 
     class Meta(SiteTable.Meta):
         fields = (
             'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
-            'vlan_count', 'circuit_count',
+            'vlan_count', 'circuit_count', 'vm_count',
         )
 
 

+ 4 - 6
netbox/dcim/views.py

@@ -25,6 +25,7 @@ from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
     ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
+from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from .constants import CONNECTION_STATUS_CONNECTED
 from .models import (
@@ -134,6 +135,7 @@ class SiteView(View):
             'prefix_count': Prefix.objects.filter(site=site).count(),
             'vlan_count': VLAN.objects.filter(site=site).count(),
             'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
+            'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(),
         }
         rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
         topology_maps = TopologyMap.objects.filter(site=site)
@@ -808,15 +810,11 @@ class DeviceView(View):
         console_ports = natsorted(
             ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name')
         )
-        cs_ports = natsorted(
-            ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name')
-        )
+        cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
         power_ports = natsorted(
             PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name')
         )
-        power_outlets = natsorted(
-            PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
-        )
+        power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port')
         interfaces = Interface.objects.order_naturally(
             device.device_type.interface_ordering
         ).filter(

+ 27 - 28
netbox/ipam/fields.py

@@ -5,10 +5,7 @@ from django.db import models
 from netaddr import IPNetwork
 
 from .formfields import IPFormField
-from .lookups import (
-    EndsWith, IEndsWith, IRegex, IStartsWith, NetContained, NetContainedOrEqual, NetContains, NetContainsOrEquals,
-    NetHost, NetHostContained, NetMaskLength, Regex, StartsWith,
-)
+from . import lookups
 
 
 def prefix_validator(prefix):
@@ -57,17 +54,18 @@ class IPNetworkField(BaseIPField):
         return 'cidr'
 
 
-IPNetworkField.register_lookup(EndsWith)
-IPNetworkField.register_lookup(IEndsWith)
-IPNetworkField.register_lookup(StartsWith)
-IPNetworkField.register_lookup(IStartsWith)
-IPNetworkField.register_lookup(Regex)
-IPNetworkField.register_lookup(IRegex)
-IPNetworkField.register_lookup(NetContained)
-IPNetworkField.register_lookup(NetContainedOrEqual)
-IPNetworkField.register_lookup(NetContains)
-IPNetworkField.register_lookup(NetContainsOrEquals)
-IPNetworkField.register_lookup(NetMaskLength)
+IPNetworkField.register_lookup(lookups.IExact)
+IPNetworkField.register_lookup(lookups.EndsWith)
+IPNetworkField.register_lookup(lookups.IEndsWith)
+IPNetworkField.register_lookup(lookups.StartsWith)
+IPNetworkField.register_lookup(lookups.IStartsWith)
+IPNetworkField.register_lookup(lookups.Regex)
+IPNetworkField.register_lookup(lookups.IRegex)
+IPNetworkField.register_lookup(lookups.NetContained)
+IPNetworkField.register_lookup(lookups.NetContainedOrEqual)
+IPNetworkField.register_lookup(lookups.NetContains)
+IPNetworkField.register_lookup(lookups.NetContainsOrEquals)
+IPNetworkField.register_lookup(lookups.NetMaskLength)
 
 
 class IPAddressField(BaseIPField):
@@ -80,16 +78,17 @@ class IPAddressField(BaseIPField):
         return 'inet'
 
 
-IPAddressField.register_lookup(EndsWith)
-IPAddressField.register_lookup(IEndsWith)
-IPAddressField.register_lookup(StartsWith)
-IPAddressField.register_lookup(IStartsWith)
-IPAddressField.register_lookup(Regex)
-IPAddressField.register_lookup(IRegex)
-IPAddressField.register_lookup(NetContained)
-IPAddressField.register_lookup(NetContainedOrEqual)
-IPAddressField.register_lookup(NetContains)
-IPAddressField.register_lookup(NetContainsOrEquals)
-IPAddressField.register_lookup(NetHost)
-IPAddressField.register_lookup(NetHostContained)
-IPAddressField.register_lookup(NetMaskLength)
+IPAddressField.register_lookup(lookups.IExact)
+IPAddressField.register_lookup(lookups.EndsWith)
+IPAddressField.register_lookup(lookups.IEndsWith)
+IPAddressField.register_lookup(lookups.StartsWith)
+IPAddressField.register_lookup(lookups.IStartsWith)
+IPAddressField.register_lookup(lookups.Regex)
+IPAddressField.register_lookup(lookups.IRegex)
+IPAddressField.register_lookup(lookups.NetContained)
+IPAddressField.register_lookup(lookups.NetContainedOrEqual)
+IPAddressField.register_lookup(lookups.NetContains)
+IPAddressField.register_lookup(lookups.NetContainsOrEquals)
+IPAddressField.register_lookup(lookups.NetHost)
+IPAddressField.register_lookup(lookups.NetHostContained)
+IPAddressField.register_lookup(lookups.NetMaskLength)

+ 24 - 6
netbox/ipam/filters.py

@@ -2,7 +2,7 @@ from __future__ import unicode_literals
 
 import django_filters
 from django.db.models import Q
-from netaddr import IPNetwork
+import netaddr
 from netaddr.core import AddrFormatError
 
 from dcim.models import Site, Device, Interface
@@ -79,7 +79,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
             return queryset
         qs_filter = Q(description__icontains=value)
         try:
-            prefix = str(IPNetwork(value.strip()).cidr)
+            prefix = str(netaddr.IPNetwork(value.strip()).cidr)
             qs_filter |= Q(prefix__net_contains_or_equals=prefix)
         except (AddrFormatError, ValueError):
             pass
@@ -107,6 +107,10 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         method='search_within_include',
         label='Within and including prefix',
     )
+    contains = django_filters.CharFilter(
+        method='search_contains',
+        label='Prefixes which contain this prefix or IP',
+    )
     mask_length = django_filters.NumberFilter(
         method='filter_mask_length',
         label='Mask length',
@@ -173,7 +177,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
             return queryset
         qs_filter = Q(description__icontains=value)
         try:
-            prefix = str(IPNetwork(value.strip()).cidr)
+            prefix = str(netaddr.IPNetwork(value.strip()).cidr)
             qs_filter |= Q(prefix__net_contains_or_equals=prefix)
         except (AddrFormatError, ValueError):
             pass
@@ -184,7 +188,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         if not value:
             return queryset
         try:
-            query = str(IPNetwork(value).cidr)
+            query = str(netaddr.IPNetwork(value).cidr)
             return queryset.filter(prefix__net_contained=query)
         except (AddrFormatError, ValueError):
             return queryset.none()
@@ -194,11 +198,25 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
         if not value:
             return queryset
         try:
-            query = str(IPNetwork(value).cidr)
+            query = str(netaddr.IPNetwork(value).cidr)
             return queryset.filter(prefix__net_contained_or_equal=query)
         except (AddrFormatError, ValueError):
             return queryset.none()
 
+    def search_contains(self, queryset, name, value):
+        value = value.strip()
+        if not value:
+            return queryset
+        try:
+            # Searching by prefix
+            if '/' in value:
+                return queryset.filter(prefix__net_contains_or_equals=str(netaddr.IPNetwork(value).cidr))
+            # Searching by IP address
+            else:
+                return queryset.filter(prefix__net_contains=str(netaddr.IPAddress(value)))
+        except (AddrFormatError, ValueError):
+            return queryset.none()
+
     def filter_mask_length(self, queryset, name, value):
         if not value:
             return queryset
@@ -291,7 +309,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
         if not value:
             return queryset
         try:
-            query = str(IPNetwork(value.strip()).cidr)
+            query = str(netaddr.IPNetwork(value.strip()).cidr)
             return queryset.filter(address__net_host_contained=query)
         except (AddrFormatError, ValueError):
             return queryset.none()

+ 17 - 5
netbox/ipam/lookups.py

@@ -13,12 +13,21 @@ class NetFieldDecoratorMixin(object):
         return lhs_string, lhs_params
 
 
+class IExact(NetFieldDecoratorMixin, lookups.IExact):
+
+    def get_rhs_op(self, connection, rhs):
+        return '= LOWER(%s)' % rhs
+
+
 class EndsWith(NetFieldDecoratorMixin, lookups.EndsWith):
-    lookup_name = 'endswith'
+    pass
 
 
 class IEndsWith(NetFieldDecoratorMixin, lookups.IEndsWith):
-    lookup_name = 'iendswith'
+    pass
+
+    def get_rhs_op(self, connection, rhs):
+        return 'LIKE LOWER(%s)' % rhs
 
 
 class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith):
@@ -26,15 +35,18 @@ class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith):
 
 
 class IStartsWith(NetFieldDecoratorMixin, lookups.IStartsWith):
-    lookup_name = 'istartswith'
+    pass
+
+    def get_rhs_op(self, connection, rhs):
+        return 'LIKE LOWER(%s)' % rhs
 
 
 class Regex(NetFieldDecoratorMixin, lookups.Regex):
-    lookup_name = 'regex'
+    pass
 
 
 class IRegex(NetFieldDecoratorMixin, lookups.IRegex):
-    lookup_name = 'iregex'
+    pass
 
 
 class NetContainsOrEquals(Lookup):

+ 0 - 4
netbox/ipam/views.py

@@ -454,9 +454,6 @@ class PrefixView(View):
         except Aggregate.DoesNotExist:
             aggregate = None
 
-        # Count child IP addresses
-        ipaddress_count = prefix.get_child_ips().count()
-
         # Parent prefixes table
         parent_prefixes = Prefix.objects.filter(
             Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
@@ -507,7 +504,6 @@ class PrefixView(View):
         return render(request, 'ipam/prefix.html', {
             'prefix': prefix,
             'aggregate': aggregate,
-            'ipaddress_count': ipaddress_count,
             'parent_prefix_table': parent_prefix_table,
             'child_prefix_table': child_prefix_table,
             'duplicate_prefix_table': duplicate_prefix_table,

+ 1 - 1
netbox/netbox/forms.py

@@ -38,7 +38,7 @@ OBJ_TYPE_CHOICES = (
 
 class SearchForm(BootstrapMixin, forms.Form):
     q = forms.CharField(
-        label='Search', widget=forms.TextInput(attrs={'style': 'width: 350px'})
+        label='Search'
     )
     obj_type = forms.ChoiceField(
         choices=OBJ_TYPE_CHOICES, required=False, label='Type'

+ 2 - 1
netbox/netbox/views.py

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from collections import OrderedDict
 
+from django.db.models import Count
 from django.shortcuts import render
 from django.views.generic import View
 from rest_framework.response import Response
@@ -58,7 +59,7 @@ SEARCH_TYPES = OrderedDict((
         'url': 'dcim:rack_list',
     }),
     ('devicetype', {
-        'queryset': DeviceType.objects.select_related('manufacturer'),
+        'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
         'filter': DeviceTypeFilter,
         'table': DeviceTypeTable,
         'url': 'dcim:devicetype_list',

+ 6 - 5
netbox/secrets/models.py

@@ -303,6 +303,7 @@ class Secret(CreatedUpdatedModel):
         |LL|MySecret|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
         +--+--------+-------------------------------------------+
         """
+        s = s.encode('utf8')
         if len(s) > 65535:
             raise ValueError("Maximum plaintext size is 65535 bytes.")
         # Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
@@ -315,7 +316,7 @@ class Secret(CreatedUpdatedModel):
         return (
             chr(len(s) >> 8).encode() +
             chr(len(s) % 256).encode() +
-            s.encode() +
+            s +
             os.urandom(pad_length)
         )
 
@@ -324,11 +325,11 @@ class Secret(CreatedUpdatedModel):
         Consume the first two bytes of s as a plaintext length indicator and return only that many bytes as the
         plaintext.
         """
-        if isinstance(s[0], int):
-            plaintext_length = (s[0] << 8) + s[1]
-        elif isinstance(s[0], str):
+        if isinstance(s[0], str):
             plaintext_length = (ord(s[0]) << 8) + ord(s[1])
-        return s[2:plaintext_length + 2].decode()
+        else:
+            plaintext_length = (s[0] << 8) + s[1]
+        return s[2:plaintext_length + 2].decode('utf8')
 
     def encrypt(self, secret_key):
         """

+ 1 - 1
netbox/secrets/views.py

@@ -166,7 +166,7 @@ def secret_edit(request, pk):
                 # Create and encrypt the new Secret
                 if master_key is not None:
                     secret = form.save(commit=False)
-                    secret.plaintext = str(form.cleaned_data['plaintext'])
+                    secret.plaintext = form.cleaned_data['plaintext']
                     secret.encrypt(master_key)
                     secret.save()
                     messages.success(request, "Modified secret {}.".format(secret))

+ 4 - 0
netbox/templates/dcim/site.html

@@ -211,6 +211,10 @@
                     <h2><a href="{% url 'circuits:circuit_list' %}?site={{ site.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
                     <p>Circuits</p>
                 </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'virtualization:virtualmachine_list' %}?site={{ site.slug }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.vm_count }}</a></h2>
+                    <p>Virtual Machines</p>
+                </div>
             </div>
         </div>
         <div class="panel panel-default">

+ 2 - 2
netbox/templates/ipam/inc/prefix_header.html

@@ -23,7 +23,7 @@
 </div>
 <div class="pull-right">
     {% if perms.ipam.add_ipaddress %}
-		<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.get_first_available_ip }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-success">
+		<a href="{% url 'ipam:ipaddress_add' %}?address={{ prefix.get_first_available_ip }}&vrf={{ prefix.vrf.pk }}&tenant_group={{ prefix.tenant.group.pk }}&tenant={{ prefix.tenant.pk }}" class="btn btn-success">
 			<span class="fa fa-plus" aria-hidden="true"></span>
 			Add an IP Address
 		</a>
@@ -45,5 +45,5 @@
 {% include 'inc/created_updated.html' with obj=prefix %}
 <ul class="nav nav-tabs" style="margin-bottom: 20px">
     <li role="presentation"{% if active_tab == 'prefix' %} class="active"{% endif %}><a href="{% url 'ipam:prefix' pk=prefix.pk %}">Prefix</a></li>
-    <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses</a></li>
+    <li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a></li>
 </ul>

+ 10 - 9
netbox/templates/ipam/prefix.html

@@ -1,4 +1,5 @@
 {% extends '_base.html' %}
+{% load helpers %}
 
 {% block title %}{{ prefix }}{% endblock %}
 
@@ -101,28 +102,28 @@
                     </td>
                 </tr>
                 <tr>
-                    <td>Is a pool</td>
+                    <td>Description</td>
                     <td>
-                        {% if prefix.is_pool %}
-                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                        {% if prefix.description %}
+                            <span>{{ prefix.description }}</span>
                         {% else %}
-                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                            <span class="text-muted">N/A</span>
                         {% endif %}
                     </td>
                 </tr>
                 <tr>
-                    <td>Description</td>
+                    <td>Is a pool</td>
                     <td>
-                        {% if prefix.description %}
-                            <span>{{ prefix.description }}</span>
+                        {% if prefix.is_pool %}
+                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
                         {% else %}
-                            <span class="text-muted">N/A</span>
+                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
                         {% endif %}
                     </td>
                 </tr>
                 <tr>
                     <td>Utilization</td>
-                    <td><a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">{{ ipaddress_count }} IP addresses</a> ({{ prefix.get_utilization }}%)</td>
+                    <td>{% utilization_graph prefix.get_utilization %}</td>
                 </tr>
             </table>
         </div>

+ 1 - 1
netbox/templates/search_form.html

@@ -1,7 +1,7 @@
 <div class="row" style="padding-bottom: 20px">
     <div class="col-md-12 text-center">
         <form action="{% url 'search' %}" method="get" class="form-inline">
-            {{ search_form.q }}
+            <input type="text" name="q" value="{{ request.GET.q }}" placeholder="Search" id="id_q" class="form-control" style="width: 350px" />
             {{ search_form.obj_type }}
             <button type="submit" class="btn btn-primary">Search</button>
         </form>

+ 7 - 1
netbox/users/views.py

@@ -55,10 +55,16 @@ class LoginView(View):
 class LogoutView(View):
 
     def get(self, request):
+
+        # Log out the user
         auth_logout(request)
         messages.info(request, "You have logged out.")
 
-        return HttpResponseRedirect(reverse('home'))
+        # Delete session key cookie (if set) upon logout
+        response = HttpResponseRedirect(reverse('home'))
+        response.delete_cookie('session_key')
+
+        return response
 
 
 #

+ 11 - 0
netbox/virtualization/filters.py

@@ -88,6 +88,17 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         queryset=Cluster.objects.all(),
         label='Cluster (ID)',
     )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        name='cluster__site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='cluster__site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
     role_id = django_filters.ModelMultipleChoiceFilter(
         queryset=DeviceRole.objects.all(),
         label='Role (ID)',

+ 5 - 0
netbox/virtualization/forms.py

@@ -344,6 +344,11 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
         label='Cluster'
     )
+    site = FilterChoiceField(
+        queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
+        to_field_name='slug',
+        null_option=(0, 'None')
+    )
     role = FilterChoiceField(
         queryset=DeviceRole.objects.filter(vm_role=True).annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',