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