1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge pull request #309 from digitalocean/vlan-groups

Closes #111: Implement VLAN groups
This commit is contained in:
Jeremy Stretch
2016-07-15 13:36:32 -04:00
committed by GitHub
14 changed files with 377 additions and 29 deletions

View File

@ -1,7 +1,7 @@
from django.contrib import admin
from .models import (
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF,
Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
)
@ -57,6 +57,14 @@ class IPAddressAdmin(admin.ModelAdmin):
return qs.select_related('vrf', 'nat_inside')
@admin.register(VLANGroup)
class VLANGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'site', 'slug']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(VLAN)
class VLANAdmin(admin.ModelAdmin):
list_display = ['site', 'vid', 'name', 'status', 'role']

View File

@ -1,7 +1,7 @@
from rest_framework import serializers
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
#
@ -73,17 +73,36 @@ class AggregateNestedSerializer(AggregateSerializer):
fields = ['id', 'family', 'prefix']
#
# VLAN groups
#
class VLANGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
class Meta:
model = VLANGroup
fields = ['id', 'name', 'slug', 'site']
class VLANGroupNestedSerializer(VLANGroupSerializer):
class Meta(VLANGroupSerializer.Meta):
fields = ['id', 'name', 'slug']
#
# VLANs
#
class VLANSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer()
group = VLANGroupNestedSerializer()
role = RoleNestedSerializer()
class Meta:
model = VLAN
fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'display_name']
class VLANNestedSerializer(VLANSerializer):

View File

@ -29,6 +29,10 @@ urlpatterns = [
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
# VLAN groups
url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
# VLANs
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),

View File

@ -1,18 +1,22 @@
from rest_framework import generics
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam import filters
from . import serializers
#
# VRFs
#
class VRFListView(generics.ListAPIView):
"""
List all VRFs
"""
queryset = VRF.objects.all()
serializer_class = serializers.VRFSerializer
filter_class = VRFFilter
filter_class = filters.VRFFilter
class VRFDetailView(generics.RetrieveAPIView):
@ -23,6 +27,10 @@ class VRFDetailView(generics.RetrieveAPIView):
serializer_class = serializers.VRFSerializer
#
# Roles
#
class RoleListView(generics.ListAPIView):
"""
List all roles
@ -39,6 +47,10 @@ class RoleDetailView(generics.RetrieveAPIView):
serializer_class = serializers.RoleSerializer
#
# RIRs
#
class RIRListView(generics.ListAPIView):
"""
List all RIRs
@ -55,13 +67,17 @@ class RIRDetailView(generics.RetrieveAPIView):
serializer_class = serializers.RIRSerializer
#
# Aggregates
#
class AggregateListView(generics.ListAPIView):
"""
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir')
serializer_class = serializers.AggregateSerializer
filter_class = AggregateFilter
filter_class = filters.AggregateFilter
class AggregateDetailView(generics.RetrieveAPIView):
@ -72,13 +88,17 @@ class AggregateDetailView(generics.RetrieveAPIView):
serializer_class = serializers.AggregateSerializer
#
# Prefixes
#
class PrefixListView(generics.ListAPIView):
"""
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
serializer_class = serializers.PrefixSerializer
filter_class = PrefixFilter
filter_class = filters.PrefixFilter
class PrefixDetailView(generics.RetrieveAPIView):
@ -89,6 +109,10 @@ class PrefixDetailView(generics.RetrieveAPIView):
serializer_class = serializers.PrefixSerializer
#
# IP addresses
#
class IPAddressListView(generics.ListAPIView):
"""
List IP addresses (filterable)
@ -96,7 +120,7 @@ class IPAddressListView(generics.ListAPIView):
queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside')
serializer_class = serializers.IPAddressSerializer
filter_class = IPAddressFilter
filter_class = filters.IPAddressFilter
class IPAddressDetailView(generics.RetrieveAPIView):
@ -108,13 +132,38 @@ class IPAddressDetailView(generics.RetrieveAPIView):
serializer_class = serializers.IPAddressSerializer
#
# VLAN groups
#
class VLANGroupListView(generics.ListAPIView):
"""
List all VLAN groups
"""
queryset = VLANGroup.objects.all()
serializer_class = serializers.VLANGroupSerializer
filter_class = filters.VLANGroupFilter
class VLANGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VLAN group
"""
queryset = VLANGroup.objects.all()
serializer_class = serializers.VLANGroupSerializer
#
# VLANs
#
class VLANListView(generics.ListAPIView):
"""
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'role')
serializer_class = serializers.VLANSerializer
filter_class = VLANFilter
filter_class = filters.VLANFilter
class VLANDetailView(generics.RetrieveAPIView):

View File

@ -4,7 +4,7 @@ from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Role
from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
class VRFFilter(django_filters.FilterSet):
@ -176,6 +176,24 @@ class IPAddressFilter(django_filters.FilterSet):
return queryset.filter(vrf__pk=value)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
class Meta:
model = VLANGroup
fields = ['site_id', 'site']
class VLANFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
name='site',
@ -188,6 +206,17 @@ class VLANFilter(django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
group_id = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
label='Group (ID)',
)
group = django_filters.ModelMultipleChoiceFilter(
name='group',
queryset=VLANGroup.objects.all(),
to_field_name='slug',
label='Group',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',

View File

@ -9,7 +9,7 @@ from utilities.forms import (
)
from .models import (
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLAN_STATUS_CHOICES, VRF,
Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
)
@ -407,22 +407,67 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin):
vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
#
# VLAN groups
#
class VLANGroupForm(forms.ModelForm, BootstrapMixin):
slug = SlugField()
class Meta:
model = VLANGroup
fields = ['site', 'name', 'slug']
class VLANGroupBulkDeleteForm(ConfirmationForm):
pk = forms.ModelMultipleChoiceField(queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput)
def vlangroup_site_choices():
site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
return [(s.slug, '{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
class VLANGroupFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
#
# VLANs
#
class VLANForm(forms.ModelForm, BootstrapMixin):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
))
class Meta:
model = VLAN
fields = ['site', 'vid', 'name', 'status', 'role']
fields = ['site', 'group', 'vid', 'name', 'status', 'role']
help_texts = {
'site': "The site at which this VLAN exists",
'group': "VLAN group (optional)",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
}
def __init__(self, *args, **kwargs):
super(VLANForm, self).__init__(*args, **kwargs)
# Limit VLAN group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class VLANFromCSVForm(forms.ModelForm):
@ -465,6 +510,11 @@ def vlan_site_choices():
return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
def vlan_group_choices():
group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
return [(g.pk, '{} ({})'.format(g, g.vlan_count)) for g in group_choices]
def vlan_status_choices():
status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@ -480,6 +530,8 @@ def vlan_role_choices():
class VLANFilterForm(forms.Form, BootstrapMixin):
site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))
group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
widget=forms.SelectMultiple(attrs={'size': 8}))
status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
widget=forms.SelectMultiple(attrs={'size': 8}))

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-15 16:22
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0010_devicebay_installed_device_set_null'),
('ipam', '0002_vrf_add_enforce_unique'),
]
operations = [
migrations.CreateModel(
name='VLANGroup',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('slug', models.SlugField()),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vlan_groups', to='dcim.Site')),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.AddField(
model_name='vlan',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'),
),
migrations.AlterUniqueTogether(
name='vlangroup',
unique_together=set([('site', 'name'), ('site', 'slug')]),
),
]

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.7 on 2016-07-15 17:14
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0003_ipam_add_vlangroups'),
]
operations = [
migrations.AlterModelOptions(
name='vlan',
options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'},
),
migrations.AlterModelOptions(
name='vlangroup',
options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
),
migrations.AlterUniqueTogether(
name='vlan',
unique_together=set([('group', 'name'), ('group', 'vid')]),
),
]

View File

@ -358,13 +358,41 @@ class IPAddress(CreatedUpdatedModel):
return None
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
class Meta:
ordering = ['site', 'name']
unique_together = [
['site', 'name'],
['site', 'slug'],
]
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
return '{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
class VLAN(CreatedUpdatedModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
to a Site, however VLAN IDs need not be unique within a Site. Like Prefixes, each VLAN is assigned an operational
status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it.
to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
within which all VLAN IDs and names but be unique.
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
MaxValueValidator(4094)
@ -374,7 +402,11 @@ class VLAN(CreatedUpdatedModel):
role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
class Meta:
ordering = ['site', 'vid']
ordering = ['site', 'group', 'vid']
unique_together = [
['group', 'vid'],
['group', 'name'],
]
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
@ -384,6 +416,12 @@ class VLAN(CreatedUpdatedModel):
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])
def clean(self):
# Validate VLAN group
if self.vlan_group and self.vlan_group.site != self.site:
raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
def to_csv(self):
return ','.join([
self.site.name,

View File

@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from utilities.tables import BaseTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
RIR_EDIT_LINK = """
@ -50,6 +50,12 @@ STATUS_LABEL = """
{% endif %}
"""
VLANGROUP_EDIT_LINK = """
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}">Edit</a>
{% endif %}
"""
#
# VRFs
@ -177,6 +183,23 @@ class IPAddressBriefTable(BaseTable):
fields = ('address', 'device', 'interface', 'nat_inside')
#
# VLAN groups
#
class VLANGroupTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan_count = tables.Column(verbose_name='VLANs')
slug = tables.Column(verbose_name='Slug')
edit = tables.TemplateColumn(template_code=VLANGROUP_EDIT_LINK, verbose_name='')
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'edit')
#
# VLANs
#
@ -185,10 +208,11 @@ class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'name', 'status', 'role')
fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role')

View File

@ -58,6 +58,12 @@ urlpatterns = [
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
# VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),

View File

@ -12,7 +12,7 @@ from utilities.views import (
)
from . import filters, forms, tables
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
def add_available_prefixes(parent, prefix_list):
@ -483,6 +483,33 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_redirect_url = 'ipam:ipaddress_list'
#
# VLAN groups
#
class VLANGroupListView(ObjectListView):
queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans'))
filter = filters.VLANGroupFilter
filter_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
template_name = 'ipam/vlangroup_list.html'
class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
cancel_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
form = forms.VLANGroupBulkDeleteForm
default_redirect_url = 'ipam:vlangroup_list'
#
# VLANs
#

View File

@ -110,7 +110,7 @@
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
@ -156,17 +156,20 @@
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
{% if perms.ipam.add_vlan %}
<li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
{% if perms.ipam.add_vlan %}
<li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
<li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
</ul>
{% else %}
<a href="{% url 'ipam:vlan_list' %}">VLANs</a>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:vlangroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLAN Groups</a></li>
{% if perms.ipam.add_vlangroup %}
<li><a href="{% url 'ipam:vlangroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>

View File

@ -0,0 +1,24 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}VLAN Groups{% endblock %}
{% block content %}
<div class="pull-right">
{% if perms.ipam.add_vlangroup %}
<a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a VLAN group
</a>
{% endif %}
</div>
<h1>VLAN Groups</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/filter_panel.html' %}
</div>
</div>
{% endblock %}