From f627e2c980bfae29737c5a064ec981bcbfec27a7 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Fri, 9 Feb 2018 10:36:57 +0100
Subject: [PATCH] Add domain table

- add domain_id on NetworkScope table to define a default domain
- add domain_id on Network (default to the Network Scope one)

Fixes INFRA-194
---
 app/api/network.py                            |  3 +-
 app/factory.py                                |  1 +
 app/models.py                                 | 26 ++++++++
 app/network/forms.py                          | 13 ++++
 app/network/views.py                          | 54 +++++++++++++---
 app/static/js/domains.js                      | 14 +++++
 app/static/js/networks.js                     | 17 +++---
 app/templates/base-fluid.html                 |  2 +
 app/templates/network/create_domain.html      | 16 +++++
 app/templates/network/create_network.html     |  1 +
 app/templates/network/create_scope.html       |  1 +
 app/templates/network/domains.html            | 33 ++++++++++
 app/templates/network/networks.html           |  1 +
 app/templates/network/scopes.html             |  1 +
 .../versions/dfd4eae61224_add_domain_table.py | 61 +++++++++++++++++++
 tests/functional/conftest.py                  |  1 +
 tests/functional/factories.py                 | 12 ++++
 tests/functional/test_api.py                  |  2 +-
 18 files changed, 242 insertions(+), 17 deletions(-)
 create mode 100644 app/static/js/domains.js
 create mode 100644 app/templates/network/create_domain.html
 create mode 100644 app/templates/network/domains.html
 create mode 100644 migrations/versions/dfd4eae61224_add_domain_table.py

diff --git a/app/api/network.py b/app/api/network.py
index 2af9584..260331b 100644
--- a/app/api/network.py
+++ b/app/api/network.py
@@ -41,9 +41,10 @@ def create_scope():
     :jsonparam first_vlan: network scope first vlan
     :jsonparam last_vlan: network scope last vlan
     :jsonparam supernet: network scope supernet
+    :jsonparam domain_id: primary key of the default domain
     """
     return create_generic_model(models.NetworkScope, mandatory_fields=(
-        'name', 'first_vlan', 'last_vlan', 'supernet'))
+        'name', 'first_vlan', 'last_vlan', 'supernet', 'domain_id'))
 
 
 @bp.route('/networks')
diff --git a/app/factory.py b/app/factory.py
index 6a05d14..47c27d2 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -101,6 +101,7 @@ def create_app(config=None):
     admin.add_view(AdminModelView(models.Status, db.session))
     admin.add_view(ItemAdmin(models.Item, db.session))
     admin.add_view(AdminModelView(models.ItemComment, db.session))
+    admin.add_view(AdminModelView(models.Domain, db.session))
     admin.add_view(AdminModelView(models.NetworkScope, db.session))
     admin.add_view(NetworkAdmin(models.Network, db.session, endpoint='networks'))
     admin.add_view(AdminModelView(models.Host, db.session))
diff --git a/app/models.py b/app/models.py
index 87f68ba..3b5749b 100644
--- a/app/models.py
+++ b/app/models.py
@@ -375,6 +375,7 @@ class Network(CreatedMixin, db.Model):
     description = db.Column(db.Text)
     admin_only = db.Column(db.Boolean, nullable=False, default=False)
     scope_id = db.Column(db.Integer, db.ForeignKey('network_scope.id'), nullable=False)
+    domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), nullable=False)
 
     interfaces = db.relationship('Interface', backref='network')
 
@@ -389,6 +390,9 @@ class Network(CreatedMixin, db.Model):
         # as a string
         if 'scope' in kwargs:
             kwargs['scope'] = utils.convert_to_model(kwargs['scope'], NetworkScope, 'name')
+            # If domain_id is not passed, we set it to the network scope value
+            if 'domain_id' not in kwargs:
+                kwargs['domain_id'] = kwargs['scope'].domain_id
         super().__init__(**kwargs)
 
     def __str__(self):
@@ -489,6 +493,7 @@ class Network(CreatedMixin, db.Model):
             'description': self.description,
             'admin_only': self.admin_only,
             'scope': utils.format_field(self.scope),
+            'domain': str(self.domain),
             'interfaces': [str(interface) for interface in self.interfaces],
         })
         return d
@@ -649,12 +654,32 @@ class Cname(CreatedMixin, db.Model):
         return d
 
 
+class Domain(CreatedMixin, db.Model):
+    name = db.Column(db.Text, nullable=False, unique=True)
+
+    scopes = db.relationship('NetworkScope', backref='domain')
+    networks = db.relationship('Network', backref='domain')
+
+    def __str__(self):
+        return str(self.name)
+
+    def to_dict(self):
+        d = super().to_dict()
+        d.update({
+            'name': self.name,
+            'scopes': [str(scope) for scope in self.scopes],
+            'networks': [str(network) for network in self.networks],
+        })
+        return d
+
+
 class NetworkScope(CreatedMixin, db.Model):
     __tablename__ = 'network_scope'
     name = db.Column(CIText, nullable=False, unique=True)
     first_vlan = db.Column(db.Integer, nullable=False, unique=True)
     last_vlan = db.Column(db.Integer, nullable=False, unique=True)
     supernet = db.Column(postgresql.CIDR, nullable=False, unique=True)
+    domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), nullable=False)
     description = db.Column(db.Text)
 
     networks = db.relationship('Network', backref='scope')
@@ -713,6 +738,7 @@ class NetworkScope(CreatedMixin, db.Model):
             'last_vlan': self.last_vlan,
             'supernet': self.supernet,
             'description': self.description,
+            'domain': str(self.domain),
             'networks': [str(network) for network in self.networks],
         })
         return d
diff --git a/app/network/forms.py b/app/network/forms.py
index 2a228b0..5af7ca6 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -41,6 +41,12 @@ class NoValidateSelectField(SelectField):
         pass
 
 
+class DomainForm(CSEntryForm):
+    name = StringField('Name',
+                       validators=[validators.InputRequired(),
+                                   Unique(models.Domain, column='name')])
+
+
 class NetworkScopeForm(CSEntryForm):
     name = StringField('Name',
                        description='name must be 3-25 characters long and contain only letters, numbers and dash',
@@ -53,6 +59,11 @@ class NetworkScopeForm(CSEntryForm):
     supernet = StringField('Supernet',
                            validators=[validators.InputRequired(),
                                        IPNetwork()])
+    domain_id = SelectField('Default domain')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.domain_id.choices = utils.get_model_choices(models.Domain, attr='name')
 
 
 class NetworkForm(CSEntryForm):
@@ -68,11 +79,13 @@ class NetworkForm(CSEntryForm):
     address = NoValidateSelectField('Address', choices=[])
     first_ip = NoValidateSelectField('First IP', choices=[])
     last_ip = NoValidateSelectField('Last IP', choices=[])
+    domain_id = SelectField('Domain')
     admin_only = BooleanField('Admin only')
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.scope_id.choices = utils.get_model_choices(models.NetworkScope, attr='name')
+        self.domain_id.choices = utils.get_model_choices(models.Domain, attr='name')
 
 
 class HostForm(CSEntryForm):
diff --git a/app/network/views.py b/app/network/views.py
index d7c09cb..3f1fe56 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -15,7 +15,7 @@ from flask import (Blueprint, render_template, jsonify, session,
                    redirect, url_for, request, flash, current_app)
 from flask_login import login_required
 from .forms import (HostForm, InterfaceForm, HostInterfaceForm, NetworkForm,
-                    NetworkScopeForm)
+                    NetworkScopeForm, DomainForm)
 from ..extensions import db
 from ..decorators import login_groups_accepted
 from .. import models, utils, helpers
@@ -214,6 +214,32 @@ def delete_interface():
     return redirect(url_for('network.view_host', name=hostname))
 
 
+@bp.route('/domains')
+@login_required
+def list_domains():
+    return render_template('network/domains.html')
+
+
+@bp.route('/domains/create', methods=('GET', 'POST'))
+@login_groups_accepted('admin')
+def create_domain():
+    form = DomainForm()
+    if form.validate_on_submit():
+        domain = models.Domain(name=form.name.data)
+        current_app.logger.debug(f'Trying to create: {domain!r}')
+        db.session.add(domain)
+        try:
+            db.session.commit()
+        except sa.exc.IntegrityError as e:
+            db.session.rollback()
+            current_app.logger.warning(f'{e}')
+            flash(f'{e}', 'error')
+        else:
+            flash(f'Domain {domain} created!', 'success')
+        return redirect(url_for('network.create_domain'))
+    return render_template('network/create_domain.html', form=form)
+
+
 @bp.route('/scopes')
 @login_required
 def list_scopes():
@@ -229,7 +255,8 @@ def create_scope():
                                     description=form.description.data or None,
                                     first_vlan=form.first_vlan.data,
                                     last_vlan=form.last_vlan.data,
-                                    supernet=form.supernet.data)
+                                    supernet=form.supernet.data,
+                                    domain_id=form.domain_id.data)
         current_app.logger.debug(f'Trying to create: {scope!r}')
         db.session.add(scope)
         try:
@@ -287,6 +314,7 @@ def retrieve_networks():
              network.address,
              network.first_ip,
              network.last_ip,
+             str(network.domain),
              network.admin_only)
             for network in models.Network.query.all()]
     return jsonify(data=data)
@@ -313,6 +341,7 @@ def create_network():
                                  address=form.address.data,
                                  first_ip=form.first_ip.data,
                                  last_ip=form.last_ip.data,
+                                 domain_id=form.domain_id.data,
                                  admin_only=form.admin_only.data)
         current_app.logger.debug(f'Trying to create: {network!r}')
         db.session.add(network)
@@ -330,15 +359,16 @@ def create_network():
     return render_template('network/create_network.html', form=form)
 
 
-@bp.route('/_retrieve_vlan_and_prefix/<int:scope_id>')
+@bp.route('/_retrieve_scope_defaults/<int:scope_id>')
 @login_required
-def retrieve_vlan_and_prefix(scope_id):
+def retrieve_scope_defaults(scope_id):
     try:
         scope = models.NetworkScope.query.get(scope_id)
     except sa.exc.DataError:
         current_app.logger.warning(f'Invalid scope_id: {scope_id}')
         data = {'vlans': [], 'prefixes': [],
-                'selected_vlan': '', 'selected_prefix': ''}
+                'selected_vlan': '', 'selected_prefix': '',
+                'domain_id': ''}
     else:
         vlans = [vlan_id for vlan_id in scope.available_vlans()]
         prefixes = scope.prefix_range()
@@ -350,7 +380,8 @@ def retrieve_vlan_and_prefix(scope_id):
         data = {'vlans': vlans,
                 'prefixes': prefixes,
                 'selected_vlan': vlans[0],
-                'selected_prefix': selected_prefix}
+                'selected_prefix': selected_prefix,
+                'domain_id': scope.domain_id}
     return jsonify(data=data)
 
 
@@ -398,6 +429,15 @@ def retrieve_scopes():
              scope.description,
              scope.first_vlan,
              scope.last_vlan,
-             scope.supernet)
+             scope.supernet,
+             str(scope.domain))
             for scope in models.NetworkScope.query.all()]
     return jsonify(data=data)
+
+
+@bp.route('/_retrieve_domains')
+@login_required
+def retrieve_domains():
+    data = [(domain.name,)
+            for domain in models.Domain.query.all()]
+    return jsonify(data=data)
diff --git a/app/static/js/domains.js b/app/static/js/domains.js
new file mode 100644
index 0000000..c0a567f
--- /dev/null
+++ b/app/static/js/domains.js
@@ -0,0 +1,14 @@
+$(document).ready(function() {
+
+  var domains_table =  $("#domains_table").DataTable({
+    "ajax": function(data, callback, settings) {
+      $.getJSON(
+        $SCRIPT_ROOT + "/network/_retrieve_domains",
+        function(json) {
+          callback(json);
+        });
+    },
+    "paging": false
+  });
+
+});
diff --git a/app/static/js/networks.js b/app/static/js/networks.js
index d4823f7..3ad026b 100644
--- a/app/static/js/networks.js
+++ b/app/static/js/networks.js
@@ -9,15 +9,16 @@ $(document).ready(function() {
     $field.val(selected_value);
   }
 
-  function update_vlan_and_prefix() {
-    // Retrieve available vlans and subnet prefixes for the selected network scope
-    // and update the vlan_id and prefix select field
+  function update_scope_defaults() {
+    // Retrieve available vlans, subnet prefixes and default domain
+    // for the selected network scope and update the linked select fields
     var scope_id = $("#scope_id").val();
     $.getJSON(
-      $SCRIPT_ROOT + "/network/_retrieve_vlan_and_prefix/" + scope_id,
+      $SCRIPT_ROOT + "/network/_retrieve_scope_defaults/" + scope_id,
       function(json) {
         update_selectfield("#vlan_id", json.data.vlans, json.data.selected_vlan);
         update_selectfield("#prefix", json.data.prefixes, json.data.selected_prefix);
+        $('#domain_id').val(json.data.domain_id);
         update_address();
       }
     );
@@ -50,14 +51,14 @@ $(document).ready(function() {
     );
   }
 
-  // Populate vlan_id and prefix select field on first page load
+  // Populate the default values linked to the scope on first page load
   if( $("#scope_id").length ) {
-    update_vlan_and_prefix();
+    update_scope_defaults();
   }
 
-  // Update vlan_id and prefix select field when changing network scope
+  // Update the default values linked to the scope when changing it
   $("#scope_id").on('change', function() {
-    update_vlan_and_prefix();
+    update_scope_defaults();
   });
 
   // Update address select field when changing prefix
diff --git a/app/templates/base-fluid.html b/app/templates/base-fluid.html
index 733a509..509133e 100644
--- a/app/templates/base-fluid.html
+++ b/app/templates/base-fluid.html
@@ -25,6 +25,8 @@
           href="{{ url_for('network.list_networks') }}">Networks</a>
         <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/scopes")) }}"
           href="{{ url_for('network.list_scopes') }}">Network Scopes</a>
+        <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/domains")) }}"
+          href="{{ url_for('network.list_domains') }}">Domains</a>
         {% endif %}
       </div>
     </div>
diff --git a/app/templates/network/create_domain.html b/app/templates/network/create_domain.html
new file mode 100644
index 0000000..e54b4bd
--- /dev/null
+++ b/app/templates/network/create_domain.html
@@ -0,0 +1,16 @@
+{% extends "network/domains.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Register Domain - CSEntry{% endblock %}
+
+{% block domains_main %}
+  <form id="DomainForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.name) }}
+    <div class="form-group row">
+      <div class="col-sm-10">
+        <button type="submit" class="btn btn-primary">Submit</button>
+      </div>
+    </div>
+  </form>
+{%- endblock %}
diff --git a/app/templates/network/create_network.html b/app/templates/network/create_network.html
index de8c9e6..506eb1f 100644
--- a/app/templates/network/create_network.html
+++ b/app/templates/network/create_network.html
@@ -14,6 +14,7 @@
     {{ render_field(form.address) }}
     {{ render_field(form.first_ip) }}
     {{ render_field(form.last_ip) }}
+    {{ render_field(form.domain_id) }}
     {{ render_field(form.admin_only) }}
     <div class="form-group row">
       <div class="col-sm-10">
diff --git a/app/templates/network/create_scope.html b/app/templates/network/create_scope.html
index 0671245..41d3ad0 100644
--- a/app/templates/network/create_scope.html
+++ b/app/templates/network/create_scope.html
@@ -11,6 +11,7 @@
     {{ render_field(form.first_vlan) }}
     {{ render_field(form.last_vlan) }}
     {{ render_field(form.supernet) }}
+    {{ render_field(form.domain_id) }}
     <div class="form-group row">
       <div class="col-sm-10">
         <button type="submit" class="btn btn-primary">Submit</button>
diff --git a/app/templates/network/domains.html b/app/templates/network/domains.html
new file mode 100644
index 0000000..0284c5f
--- /dev/null
+++ b/app/templates/network/domains.html
@@ -0,0 +1,33 @@
+{% extends "base-fluid.html" %}
+{% from "_helpers.html" import is_active %}
+
+{% block title %}Domains - CSEntry{% endblock %}
+
+{% block main %}
+  {% set path = request.path %}
+  <ul class="nav nav-tabs">
+    <li class="nav-item">
+      <a class="nav-link {{ is_active(path.endswith("/network/domains")) }}" href="{{ url_for('network.list_domains') }}">List domains</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link {{ is_active(path.startswith("/network/domains/create")) }}" href="{{ url_for('network.create_domain') }}">Register new domain</a>
+    </li>
+    {% block domains_nav %}{% endblock %}
+  </ul>
+
+  <br>
+
+  {% block domains_main %}
+  <table id="domains_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%">
+    <thead>
+      <tr>
+        <th>Name</th>
+      </tr>
+    </thead>
+  </table>
+  {%- endblock %}
+{%- endblock %}
+
+{% block csentry_scripts %}
+  <script src="{{ url_for('static', filename='js/domains.js') }}"></script>
+{% endblock %}
diff --git a/app/templates/network/networks.html b/app/templates/network/networks.html
index 52f508c..0ab11ff 100644
--- a/app/templates/network/networks.html
+++ b/app/templates/network/networks.html
@@ -28,6 +28,7 @@
         <th>Address</th>
         <th>First IP</th>
         <th>Last IP</th>
+        <th>Domain</th>
         <th>Admin only</th>
       </tr>
     </thead>
diff --git a/app/templates/network/scopes.html b/app/templates/network/scopes.html
index 0c56c08..a67676f 100644
--- a/app/templates/network/scopes.html
+++ b/app/templates/network/scopes.html
@@ -26,6 +26,7 @@
         <th>First vlan</th>
         <th>Last vlan</th>
         <th>Supernet</th>
+        <th>Default domain</th>
       </tr>
     </thead>
   </table>
diff --git a/migrations/versions/dfd4eae61224_add_domain_table.py b/migrations/versions/dfd4eae61224_add_domain_table.py
new file mode 100644
index 0000000..d85c102
--- /dev/null
+++ b/migrations/versions/dfd4eae61224_add_domain_table.py
@@ -0,0 +1,61 @@
+"""Add domain table
+
+Revision ID: dfd4eae61224
+Revises: 713ca10255ab
+Create Date: 2018-02-09 09:32:32.221007
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'dfd4eae61224'
+down_revision = '713ca10255ab'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    domain = op.create_table(
+        'domain',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('created_at', sa.DateTime(), nullable=True),
+        sa.Column('updated_at', sa.DateTime(), nullable=True),
+        sa.Column('name', sa.Text(), nullable=False),
+        sa.Column('user_id', sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_domain_user_id_user_account')),
+        sa.PrimaryKeyConstraint('id', name=op.f('pk_domain')),
+        sa.UniqueConstraint('name', name=op.f('uq_domain_name'))
+    )
+    # WARNING! If the database is not emppty, we can't set the domain_id to nullable=False before adding a value!
+    op.add_column('network', sa.Column('domain_id', sa.Integer(), nullable=True))
+    op.create_foreign_key(op.f('fk_network_domain_id_domain'), 'network', 'domain', ['domain_id'], ['id'])
+    op.add_column('network_scope', sa.Column('domain_id', sa.Integer(), nullable=True))
+    op.create_foreign_key(op.f('fk_network_scope_domain_id_domain'), 'network_scope', 'domain', ['domain_id'], ['id'])
+    # Try to get a user_id (required to create a domain)
+    conn = op.get_bind()
+    res = conn.execute('SELECT id FROM user_account LIMIT 1')
+    results = res.fetchall()
+    # If no user was found, then the database is empty - no need to add a default value
+    if results:
+        user_id = results[0][0]
+        # Create a default domain
+        op.execute(domain.insert().values(id=1, user_id=user_id, name='example.org',
+                                          created_at=sa.func.now(), updated_at=sa.func.now()))
+        # Add default domain_id value to network_scope and network
+        network_scope = sa.sql.table('network_scope', sa.sql.column('domain_id'))
+        op.execute(network_scope.update().values(domain_id=1))
+        network = sa.sql.table('network', sa.sql.column('domain_id'))
+        op.execute(network.update().values(domain_id=1))
+    # Add the nullable=False constraint
+    op.alter_column('network', 'domain_id', nullable=False)
+    op.alter_column('network_scope', 'domain_id', nullable=False)
+
+
+def downgrade():
+    op.drop_constraint(op.f('fk_network_scope_domain_id_domain'), 'network_scope', type_='foreignkey')
+    op.drop_column('network_scope', 'domain_id')
+    op.drop_constraint(op.f('fk_network_domain_id_domain'), 'network', type_='foreignkey')
+    op.drop_column('network', 'domain_id')
+    op.drop_table('domain')
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index 492442f..55a3c12 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -29,6 +29,7 @@ register(factories.NetworkFactory)
 register(factories.InterfaceFactory)
 register(factories.HostFactory)
 register(factories.MacFactory)
+register(factories.DomainFactory)
 
 
 @pytest.fixture(scope='session')
diff --git a/tests/functional/factories.py b/tests/functional/factories.py
index 09ad58b..d0554a6 100644
--- a/tests/functional/factories.py
+++ b/tests/functional/factories.py
@@ -89,6 +89,16 @@ class ItemFactory(factory.alchemy.SQLAlchemyModelFactory):
     user = factory.SubFactory(UserFactory)
 
 
+class DomainFactory(factory.alchemy.SQLAlchemyModelFactory):
+    class Meta:
+        model = models.Domain
+        sqlalchemy_session = common.Session
+        sqlalchemy_session_persistence = 'commit'
+
+    user = factory.SubFactory(UserFactory)
+    name = factory.Sequence(lambda n: f'domain{n}.example.org')
+
+
 class NetworkScopeFactory(factory.alchemy.SQLAlchemyModelFactory):
     class Meta:
         model = models.NetworkScope
@@ -100,6 +110,7 @@ class NetworkScopeFactory(factory.alchemy.SQLAlchemyModelFactory):
     last_vlan = factory.Sequence(lambda n: 1609 + 10 * n)
     supernet = factory.Faker('ipv4', network=True)
     user = factory.SubFactory(UserFactory)
+    domain = factory.SubFactory(DomainFactory)
 
 
 class NetworkFactory(factory.alchemy.SQLAlchemyModelFactory):
@@ -113,6 +124,7 @@ class NetworkFactory(factory.alchemy.SQLAlchemyModelFactory):
     address = factory.Faker('ipv4', network=True)
     scope = factory.SubFactory(NetworkScopeFactory)
     user = factory.SubFactory(UserFactory)
+    domain = factory.SubFactory(DomainFactory)
 
     @factory.lazy_attribute
     def first_ip(self):
diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py
index a6909c2..2f5fa75 100644
--- a/tests/functional/test_api.py
+++ b/tests/functional/test_api.py
@@ -465,7 +465,7 @@ def test_create_network(client, admin_token, network_scope_factory):
     assert response.status_code == 201
     assert {'id', 'vlan_name', 'vlan_id', 'address', 'first_ip',
             'last_ip', 'description', 'admin_only', 'scope',
-            'interfaces', 'created_at', 'updated_at',
+            'domain', 'interfaces', 'created_at', 'updated_at',
             'user'} == set(response.json.keys())
     assert response.json['vlan_name'] == 'network1'
     assert response.json['vlan_id'] == 1600
-- 
GitLab