Commit f08c1e86 by Őry Máté

Merge branch 'feature-network-gui-improvements' into 'master'

Feature network gui improvements

* closes #358 Automatically select a free ipv4 address based on vlan (host creation)
* closes #357 Automatically generate ipv6 address from ipv4 (host creation/edit)
* closes #354 MAC address autocomplete
* closes #360 Add sensible default and help_text to Vlan.ipv6_template

See merge request !273
parents 8a96b4f8 6694020e
......@@ -15,6 +15,7 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <>.
from string import ascii_letters
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
......@@ -22,7 +23,7 @@ from django.utils.ipv6 import is_valid_ipv6_address
from south.modelsinspector import add_introspection_rules
from django import forms
from netaddr import (IPAddress, IPNetwork, AddrFormatError, ZEROFILL,
EUI, mac_unix)
EUI, mac_unix, AddrConversionError)
import re
......@@ -31,7 +32,6 @@ domain_re = re.compile(r'^([A-Za-z0-9_-]\.?)+$')
domain_wildcard_re = re.compile(r'^(\*\.)?([A-Za-z0-9_-]\.?)+$')
ipv4_re = re.compile('^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$')
reverse_domain_re = re.compile(r'^(%\([abcd]\)d|[a-z0-9.-])+$')
ipv6_template_re = re.compile(r'^(%\([abcd]\)[dxX]|[A-Za-z0-9:-])+$')
class mac_custom(mac_unix):
......@@ -246,15 +246,60 @@ def val_reverse_domain(value):
raise ValidationError(u'%s - invalid reverse domain name' % value)
def is_valid_ipv6_template(value):
"""Check whether the parameter is a valid ipv6 template."""
return ipv6_template_re.match(value) is not None
def val_ipv6_template(value):
"""Validate whether the parameter is a valid ipv6 template."""
if not is_valid_ipv6_template(value):
raise ValidationError(u'%s - invalid reverse ipv6 template' % value)
"""Validate whether the parameter is a valid ipv6 template.
Normal use:
>>> val_ipv6_template("123::%(a)d:%(b)d:%(c)d:%(d)d")
>>> val_ipv6_template("::%(a)x:%(b)x:%(c)d:%(d)d")
Don't have to use all bytes from the left (no a):
>>> val_ipv6_template("::%(b)x:%(c)d:%(d)d")
But have to use all ones to the right (a, but no b):
>>> val_ipv6_template("::%(a)x:%(c)d:%(d)d")
Traceback (most recent call last):
ValidationError: [u"template doesn't use parameter b"]
Detects valid templates building invalid ips:
>>> val_ipv6_template("xxx::%(a)d:%(b)d:%(c)d:%(d)d")
Traceback (most recent call last):
ValidationError: [u'template renders invalid IPv6 address']
Also IPv4-compatible addresses are invalid:
>>> val_ipv6_template("::%(a)02x%(b)02x:%(c)d:%(d)d")
Traceback (most recent call last):
ValidationError: [u'template results in IPv4 address']
tpl = {ascii_letters[i]: 255 for i in range(4)}
v6 = value % tpl
raise ValidationError(_('%s: invalid template') % value)
used = False
for i in ascii_letters[:4]:
value % {k: tpl[k] for k in tpl if k != i}
except KeyError:
used = True # ok, it misses this key
if used:
raise ValidationError(
_("template doesn't use parameter %s") % i)
v6 = IPAddress(v6, 6)
raise ValidationError(_('template renders invalid IPv6 address'))
except (AddrConversionError, AddrFormatError):
pass # can't converted to ipv4 == it's real ipv6
raise ValidationError(_('template results in IPv4 address'))
def is_valid_ipv4_address(value):
......@@ -284,12 +329,3 @@ def val_mx(value):
raise ValidationError(_("Bad MX address format. "
"Should be: <priority>:<hostname>"))
def convert_ipv4_to_ipv6(ipv6_template, ipv4):
"""Convert IPv4 address string to IPv6 address string."""
m = ipv4.words
return IPAddress(ipv6_template % {'a': int(m[0]),
'b': int(m[1]),
'c': int(m[2]),
'd': int(m[3])})
......@@ -17,9 +17,11 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <>.
from itertools import islice, ifilter
from string import ascii_letters
from itertools import islice, ifilter, chain
from math import ceil
import logging
from netaddr import IPSet, EUI, IPNetwork
import random
from django.contrib.auth.models import User
from django.db import models
......@@ -28,15 +30,17 @@ from django.utils.translation import ugettext_lazy as _
from firewall.fields import (MACAddressField, val_alfanum, val_reverse_domain,
val_ipv6_template, val_domain, val_ipv4,
val_ipv6, val_mx, convert_ipv4_to_ipv6,
val_ipv6, val_mx,
IPNetworkField, IPAddressField)
from django.core.validators import MinValueValidator, MaxValueValidator
import django.conf
from django.db.models.signals import post_save, post_delete
import random
from celery.exceptions import TimeoutError
from netaddr import IPSet, EUI, IPNetwork, IPAddress, ipv6_full
from common.models import method_cache, WorkerNotFound, HumanSortField
from firewall.tasks.local_tasks import reloadtask
from firewall.tasks.remote_tasks import get_dhcp_clients
from .iptables import IptRule
from acl.models import AclBase
......@@ -360,9 +364,19 @@ class Vlan(AclBase, models.Model):
'address is: "%(d)d.%(c)d.%(b)d.%(a)".'),
ipv6_template = models.TextField(
verbose_name=_('ipv6 template'),
help_text=_('Template for translating IPv4 addresses to IPv6. '
'Automatically generated hosts in dual-stack networks '
'will get this address. The template '
'can contain four tokens: "%(a)d", "%(b)d", '
'"%(c)d", and "%(d)d", representing the four bytes '
'of the IPv4 address, respectively, in decimal notation. '
'Moreover you can use any standard printf format '
'specification like %(a)02x to get the first byte as two '
'hexadecimal digits. Usual choices for mapping '
' to 2001:0DB8:1:1::/64 would be '
'"2001:db8:1:1:%(d)d::" and "2001:db8:1:1:%(d)02x00::".'),
validators=[val_ipv6_template], verbose_name=_('ipv6 template'))
dhcp_pool = models.TextField(blank=True, verbose_name=_('DHCP pool'),
'The address range of the DHCP pool: '
......@@ -377,6 +391,87 @@ class Vlan(AclBase, models.Model):
modified_at = models.DateTimeField(auto_now=True,
verbose_name=_('modified at'))
def clean(self):
super(Vlan, self).clean()
if self.ipv6_template:
if not self.network6:
raise ValidationError(
_("You cannot specify an IPv6 template if there is no "
"IPv6 network set."))
for i in (self.network4[1], self.network4[-1]):
i6 = self.convert_ipv4_to_ipv6(i)
if i6 not in self.network6:
raise ValidationError(
_("%(ip6)s (translated from %(ip4)s) is outside of "
"the IPv6 network.") % {"ip4": i, "ip6": i6})
if self.network6:
tpl, prefixlen = self._magic_ipv6_template(self.network4,
if not self.ipv6_template:
self.ipv6_template = tpl
if not self.host_ipv6_prefixlen:
self.host_ipv6_prefixlen = prefixlen
def _host_bytes(prefixlen, maxbytes):
return int(ceil((maxbytes - prefixlen / 8.0)))
def _append_hexa(s, v, lasthalf):
if lasthalf: # can use last half word
assert s[-1] == "0" or s[-1].endswith("00")
if s[-1].endswith("00"):
s[-1] = s[-1][:-2]
s[-1] += "%({})02x".format(v)
def _magic_ipv6_template(cls, network4, network6, verbose=None):
"""Offer a sensible ipv6_template value.
Based on prefix lengths the method magically selects verbose (decimal)
>>> Vlan._magic_ipv6_template(IPNetwork(""),
... IPNetwork("2001:0DB8:1:1::/64"))
('2001:db8:1:1:%(d)d::', 80)
However you can explicitly select non-verbose, i.e. hexa format:
>>> Vlan._magic_ipv6_template(IPNetwork(""),
... IPNetwork("2001:0DB8:1:1::/64"), False)
('2001:db8:1:1:%(d)02x00::', 72)
host4_bytes = cls._host_bytes(network4.prefixlen, 4)
host6_bytes = cls._host_bytes(network6.prefixlen, 16)
if host4_bytes > host6_bytes:
raise ValidationError(
_("IPv6 network is too small to map IPv4 addresses to it."))
letters = ascii_letters[4-host4_bytes:4]
remove = host6_bytes // 2
ipstr =
s = ipstr.split(":")[0:-remove]
if verbose is None: # use verbose format if net6 much wider
verbose = 2 * (host4_bytes + 1) < host6_bytes
if verbose:
for i in letters:
remain = host6_bytes
for i in letters:
cls._append_hexa(s, i, remain % 2 == 1)
remain -= 1
if host6_bytes > host4_bytes:
tpl = ":".join(s)
# compute prefix length
mask = int(IPAddress(tpl % {"a": 1, "b": 1, "c": 1, "d": 1}))
prefixlen = 128
while mask % 2 == 0:
mask /= 2
prefixlen -= 1
return (tpl, prefixlen)
def __unicode__(self):
return "%s - %s" % ("managed" if self.managed else "unmanaged",
......@@ -402,7 +497,7 @@ class Vlan(AclBase, models.Model):
logger.debug("Found unused IPv4 address %s.", ipv4)
ipv6 = None
if self.network6 is not None:
ipv6 = convert_ipv4_to_ipv6(self.ipv6_template, ipv4)
ipv6 = self.convert_ipv4_to_ipv6(ipv4)
if ipv6 in used_v6:
......@@ -411,6 +506,20 @@ class Vlan(AclBase, models.Model):
raise ValidationError(_("All IP addresses are already in use."))
def convert_ipv4_to_ipv6(self, ipv4):
"""Convert IPv4 address string to IPv6 address string."""
if isinstance(ipv4, basestring):
ipv4 = IPAddress(ipv4, 4)
nums = {ascii_letters[i]: int(ipv4.words[i]) for i in range(4)}
return IPAddress(self.ipv6_template % nums)
def get_dhcp_clients(self):
macs = set(i.mac for i in self.host_set.all())
return [{"mac": k, "ip": v["ip"], "hostname": v["hostname"]}
for k, v in chain(*(fw.get_dhcp_clients().iteritems()
for fw in Firewall.objects.all() if fw))
if v["interface"] == and EUI(k) not in macs]
class VlanGroup(models.Model):
......@@ -581,8 +690,7 @@ class Host(models.Model):
def save(self, *args, **kwargs):
if not and self.ipv6 == "auto":
self.ipv6 = convert_ipv4_to_ipv6(self.vlan.ipv6_template,
self.ipv6 = self.vlan.convert_ipv4_to_ipv6(self.ipv4)
super(Host, self).save(*args, **kwargs)
......@@ -838,7 +946,7 @@ class Firewall(models.Model):
def get_remote_queue_name(self, queue_id):
def get_remote_queue_name(self, queue_id="firewall"):
"""Returns the name of the remote celery queue for this node.
Throws Exception if there is no worker on the queue.
......@@ -851,6 +959,20 @@ class Firewall(models.Model):
raise WorkerNotFound()
def get_dhcp_clients(self):
return get_dhcp_clients.apply_async(
queue=self.get_remote_queue_name(), expires=60).get(timeout=2)
except TimeoutError:"get_dhcp_clients task timed out")
except IOError:
logger.exception("get_dhcp_clients failed. "
"maybe syslog isn't readble by firewall worker")
logger.exception("get_dhcp_clients failed")
return None
class Domain(models.Model):
name = models.CharField(max_length=40, validators=[val_domain],
......@@ -62,5 +62,6 @@ def reload_blacklist(data):
def get_dhcp_clients(data):
def get_dhcp_clients():
# {'00:21:5a:73:72:cd': {'interface': 'OFF', 'ip': None, 'hostname': None}}
......@@ -78,6 +78,7 @@ class GetNewAddressTestCase(TestCase):
self.vlan = Vlan(vid=1, name='test', network4='',
network6='2001:738:2001:4031::/80', domain=d,
for i in [1] + range(3, 6):
......@@ -85,6 +86,9 @@ class GetNewAddressTestCase(TestCase):
ipv4='10.0.0.%d' % i, vlan=self.vlan,
def tearDown(self):
def test_new_addr_w_empty_vlan(self):
......@@ -96,12 +100,6 @@ class GetNewAddressTestCase(TestCase):
self.assertRaises(ValidationError, self.vlan.get_new_address)
def test_all_addr_in_use_w_ipv6(self):
Host(hostname='h-x', mac='01:02:03:04:05:06',
ipv4='', ipv6='2001:738:2001:4031:0:0:2:0',
vlan=self.vlan, owner=self.u1).save()
self.assertRaises(ValidationError, self.vlan.get_new_address)
def test_new_addr(self):
used_v4 = IPSet(self.vlan.host_set.values_list('ipv4', flat=True))
assert self.vlan.get_new_address()['ipv4'] not in used_v4
......@@ -15,13 +15,13 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <>.
from django.forms import ModelForm
from django.forms import ModelForm, widgets
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Fieldset, Div, Submit, BaseInput
from crispy_forms.bootstrap import FormActions
from crispy_forms.bootstrap import FormActions, FieldWithButtons, StrictButton
from firewall.models import (Host, Vlan, Domain, Group, Record, BlacklistItem,
Rule, VlanGroup, SwitchPort)
......@@ -122,8 +122,12 @@ class HostForm(ModelForm):
FieldWithButtons('ipv4', StrictButton(
'<i class="fa fa-magic"></i>', css_id="ipv4-magic",
title=_("Generate random address."))),
FieldWithButtons('ipv6', StrictButton(
'<i class="fa fa-magic"></i>', css_id="ipv6-magic",
title=_("Generate IPv6 pair of IPv4 address."))),
......@@ -252,7 +256,9 @@ class VlanForm(ModelForm):
FieldWithButtons('ipv6_template', StrictButton(
'<i class="fa fa-magic"></i>', css_id="ipv6-tpl-magic",
title=_("Generate sensible template."))),
......@@ -277,6 +283,9 @@ class VlanForm(ModelForm):
class Meta:
model = Vlan
widgets = {
'ipv6_template': widgets.TextInput,
class VlanGroupForm(ModelForm):
......@@ -28,6 +28,49 @@ function getURLParameter(name) {
function doBlink(id, count) {
if (count > 0) {
$(id).parent().delay(200).queue(function() {
$(this).delay(200).queue(function() {
doBlink(id, count-1);});
$(function() {
$("#ipv6-magic").click(function() {
$.ajax({url: window.location,
data: {ipv4: $("[name=ipv4]").val(),
vlan: $("[name=vlan]").val()},
success: function(data) {
$("#ipv4-magic").click(function() {
$.ajax({url: window.location,
data: {vlan: $("[name=vlan]").val()},
success: function(data) {
if ($("[name=ipv6]").val() != data.ipv6) {
doBlink("[name=ipv6]", 3);
$("#ipv6-tpl-magic").click(function() {
$.ajax({url: window.location,
data: {network4: $("[name=network4]").val(),
network6: $("[name=network6]").val()},
success: function(data) {
if ($("[name=host_ipv6_prefixlen]").val() != data.host_ipv6_prefixlen) {
doBlink("[name=host_ipv6_prefixlen]", 3);
......@@ -15,14 +15,30 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <>.
from netaddr import EUI
from django.utils.translation import ugettext_lazy as _
from django.utils.html import format_html
from django_tables2 import Table, A
from django_tables2.columns import LinkColumn, TemplateColumn
from django_tables2.columns import LinkColumn, TemplateColumn, Column
from firewall.models import Host, Vlan, Domain, Group, Record, Rule, SwitchPort
class MACColumn(Column):
def render(self, value):
if isinstance(value, basestring):
value = EUI(value)
return value
return format_html('<abbr title="{0}">{1}</abbr>',
value.oui.registration().org, value)
return value
class BlacklistItemTable(Table):
ipv4 = LinkColumn('network.blacklist', args=[A('pk')])
......@@ -55,9 +71,7 @@ class GroupTable(Table):
class HostTable(Table):
hostname = LinkColumn('', args=[A('pk')])
mac = TemplateColumn(
mac = MACColumn()
class Meta:
model = Host
......@@ -108,6 +122,20 @@ class SmallHostTable(Table):
attrs = {'class': 'table table-striped table-condensed'}
fields = ('hostname', 'ipv4')
order_by = ('vlan', 'hostname', )
empty_text = _("No hosts.")
class SmallDhcpTable(Table):
mac = MACColumn(verbose_name=_("MAC address"))
hostname = Column(verbose_name=_("hostname"))
ip = Column(verbose_name=_("requested IP"))
register = TemplateColumn(
attrs={"th": {"style": "display: none;"}})
class Meta:
attrs = {'class': 'table table-striped table-condensed'}
empty_text = _("No hosts.")
class RecordTable(Table):
{% load i18n %}
<a href="{% url "network.host_create" %}?vlan={{ }}&amp;mac={{ record.mac }}&amp;hostname={{ record.hostname }}&amp;ipv4={{ record.ip }}"
title="{% trans "register host" %}"><i class="fa fa-plus-circle"></i></a>
{% load i18n %}
<span title="{% blocktrans with vendor=record.hw_vendor|default:"n/a" %}Vendor: {{vendor}}{% endblocktrans %}">{{ record.mac }}</span>
......@@ -19,9 +19,16 @@
<div class="col-sm-6">
<div class="page-header">
<a href="{% url "network.host_create" %}?vlan={{}}" class="btn btn-success pull-right"><i class="fa fa-plus-circle"></i> {% trans "Create a new host" %}</a>
<h3>{% trans "Host list" %}</h3>
{% render_table host_list %}
<div class="page-header">
<h3>{% trans "Unregistered hosts" %}</h3>
{% render_table dhcp_list %}
<div class="page-header">
<h3>{% trans "Manage access" %}</h3>
......@@ -15,11 +15,13 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <>.
from netaddr import IPNetwork
from django.views.generic import (TemplateView, UpdateView, DeleteView,
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse_lazy
from django.shortcuts import render, redirect
from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse, Http404
from django_tables2 import SingleTableView
......@@ -29,7 +31,7 @@ from vm.models import Interface
from .tables import (HostTable, VlanTable, SmallHostTable, DomainTable,
GroupTable, RecordTable, BlacklistItemTable, RuleTable,
VlanGroupTable, SmallRuleTable, SmallGroupRuleTable,
SmallRecordTable, SwitchPortTable)
SmallRecordTable, SwitchPortTable, SmallDhcpTable, )
from .forms import (HostForm, VlanForm, DomainForm, GroupForm, RecordForm,
BlacklistItemForm, RuleForm, VlanGroupForm, SwitchPortForm)
......@@ -41,10 +43,36 @@ from braces.views import LoginRequiredMixin, SuperuserRequiredMixin
# from django.db.models import Q
from operator import itemgetter
from itertools import chain
import json
from dashboard.views import AclUpdateView
from dashboard.forms import AclUserOrGroupAddForm
from django.utils import simplejson
from django.http import JsonResponse
except ImportError:
class JsonResponse(HttpResponse):
"""JSON response for Django < 1.7
def __init__(self, content, mimetype='application/json',
status=None, content_type=None):
super(JsonResponse, self).__init__(
class MagicMixin(object):
def get(self, *args, **kwargs):
if self.request.is_ajax():
result = self._get_ajax(*args, **kwargs)
return JsonResponse({k: unicode(result[k] or "") for k in result})
return super(MagicMixin, self).get(*args, **kwargs)
class InitialOwnerMixin(FormMixin):
def get_initial(self):
......@@ -310,6 +338,25 @@ class GroupDelete(LoginRequiredMixin, SuperuserRequiredMixin, DeleteView):
return context
class HostMagicMixin(MagicMixin):
def _get_ajax(self, *args, **kwargs):
GET = self.request.GET
result = {}
vlan = get_object_or_404(Vlan.objects, pk=GET.get("vlan", ""))
if "ipv4" in GET:
result["ipv6"] = vlan.convert_ipv4_to_ipv6(GET["ipv4"]) or ""
result["ipv6"] = ""
except ValidationError:
result["ipv4"] = ""
result["ipv6"] = ""
return result
class HostList(LoginRequiredMixin, SuperuserRequiredMixin, SingleTableView):
model = Host
table_class = HostTable
......@@ -331,15 +378,15 @@ class HostList(LoginRequiredMixin, SuperuserRequiredMixin, SingleTableView):
return data
class HostDetail(LoginRequiredMixin, SuperuserRequiredMixin,
class HostDetail(HostMagicMixin, LoginRequiredMixin, SuperuserRequiredMixin,
SuccessMessageMixin, UpdateView):
model = Host
template_name = "network/host-edit.html"
form_class = HostForm
success_message = _(u'Successfully modified host %(hostname)s!')
def get(self, request, *args, **kwargs):
if request.is_ajax():
def _get_ajax(self, *args, **kwargs):
if "vlan" not in self.request.GET:
host = Host.objects.get(pk=kwargs['pk'])
host = {
'hostname': host.hostname,
......@@ -347,11 +394,13 @@ class HostDetail(LoginRequiredMixin, SuperuserRequiredMixin,
'ipv6': str(host.ipv6),
'fqdn': host.get_fqdn()
return HttpResponse(json.dumps(host),
return host
self.object = self.get_object()
return super(HostDetail, self).get(request, *args, **kwargs)
return super(HostDetail, self)._get_ajax(*args, **kwargs)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super(HostDetail, self).get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
pk = self.kwargs.get('pk')
......@@ -402,13 +451,29 @@ class HostDetail(LoginRequiredMixin, SuperuserRequiredMixin,
return reverse_lazy('', kwargs=self.kwargs)
class HostCreate(LoginRequiredMixin, SuperuserRequiredMixin,
class HostCreate(HostMagicMixin, LoginRequiredMixin, SuperuserRequiredMixin,
SuccessMessageMixin, InitialOwnerMixin, CreateView):
model = Host
template_name = "network/host-create.html"
form_class = HostForm
success_message = _(u'Successfully created host %(hostname)s!')
def get_initial(self):
initial = super(HostCreate, self).get_initial()
for i in ("vlan", "mac", "hostname"):
if i in self.request.GET and i not in self.request.POST:
initial[i] = self.request.GET[i]
if "vlan" in initial:
if not initial['vlan'].isnumeric():
raise Http404()
vlan = get_object_or_404(Vlan.objects, pk=initial['vlan'])
except ValidationError as e:
messages.error(self.request, e.message)
return initial
class HostDelete(LoginRequiredMixin, SuperuserRequiredMixin, DeleteView):
model = Host
......@@ -644,7 +709,21 @@ class VlanAclUpdateView(AclUpdateView):
model = Vlan
class VlanDetail(LoginRequiredMixin, SuperuserRequiredMixin,
class VlanMagicMixin(MagicMixin):
def _get_ajax(self, *args, **kwargs):
GET = self.request.GET
result = {}
if "network4" in GET and "network6" in GET:
result["ipv6_template"], result["host_ipv6_prefixlen"] = (
result["ipv6_template"] = result["host_ipv6_prefixlen"] = ""
return result
class VlanDetail(VlanMagicMixin, LoginRequiredMixin, SuperuserRequiredMixin,
SuccessMessageMixin, UpdateView):
model = Vlan
template_name = "network/vlan-edit.html"
......@@ -656,6 +735,7 @@ class VlanDetail(LoginRequiredMixin, SuperuserRequiredMixin,
def get_context_data(self, **kwargs):
context = super(VlanDetail, self).get_context_data(**kwargs)
context['host_list'] = SmallHostTable(self.object.host_set.all())
context['dhcp_list'] = SmallDhcpTable(self.object.get_dhcp_clients())
context['vlan_vid'] = self.kwargs.get('vid')
context['acl'] = AclUpdateView.get_acl_data(
self.object, self.request.user, 'network.vlan-acl')
......@@ -665,7 +745,7 @@ class VlanDetail(LoginRequiredMixin, SuperuserRequiredMixin,
success_url = reverse_lazy('network.vlan_list')
class VlanCreate(LoginRequiredMixin, SuperuserRequiredMixin,
class VlanCreate(VlanMagicMixin, LoginRequiredMixin, SuperuserRequiredMixin,
SuccessMessageMixin, InitialOwnerMixin, CreateView):
model = Vlan