diff --git a/app/api/network.py b/app/api/network.py index e5f689df5ac4538a42f889456670d8d943a6bf8c..d47f628432a35cb140c347c6bfb05c5f172b9937 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -141,11 +141,11 @@ def create_host(): .. :quickref: Network; Create new host :jsonparam name: hostname - :jsonparam type: Physical|Virtual + :jsonparam machine_type: Physical|Virtual|... :jsonparam description: (optional) description :jsonparam item_id: (optional) linked item primary key """ - return create_generic_model(models.Host, mandatory_fields=('name',)) + return create_generic_model(models.Host, mandatory_fields=('name', 'machine_type')) @bp.route('/macs') diff --git a/app/models.py b/app/models.py index d726733fbf9be27327924d70f8af1fd7840c46bc..2dce67c34fea0432be66f23b8531238e9b3f2316 100644 --- a/app/models.py +++ b/app/models.py @@ -520,14 +520,38 @@ class Tag(QRCodeMixin, db.Model): admin_only = db.Column(db.Boolean, nullable=False, default=False) +class MachineType(db.Model): + __tablename__ = 'machine_type' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(CIText, nullable=False, unique=True) + + hosts = db.relationship('Host', backref='machine_type') + + def __str__(self): + return self.name + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'hosts': [str(host) for host in self.hosts] + } + + class Host(CreatedMixin, db.Model): name = db.Column(db.Text, nullable=False, unique=True) - type = db.Column(db.Text) description = db.Column(db.Text) + machine_type_id = db.Column(db.Integer, db.ForeignKey('machine_type.id'), nullable=False) item_id = db.Column(db.Integer, db.ForeignKey('item.id')) interfaces = db.relationship('Interface', backref='host') + def __init__(self, **kwargs): + # Automatically convert machine_type as an instance of its class if passed as a string + if 'machine_type' in kwargs: + kwargs['machine_type'] = utils.convert_to_model(kwargs['machine_type'], MachineType) + super().__init__(**kwargs) + def __str__(self): return str(self.name) @@ -546,7 +570,7 @@ class Host(CreatedMixin, db.Model): d = super().to_dict() d.update({ 'name': self.name, - 'type': self.type, + 'machine_type': str(self.machine_type), 'description': self.description, 'item': utils.format_field(self.item), 'interfaces': [str(interface) for interface in self.interfaces], diff --git a/app/network/forms.py b/app/network/forms.py index 268bbb3de3319205f1ecc061ac252b603bf961a1..21d17633dba163c14bc70cbde98c019848f79356 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -124,12 +124,13 @@ class HostForm(CSEntryForm): validators.Regexp(HOST_NAME_RE), Unique(models.Host)], filters=[utils.lowercase_field]) - type = SelectField('Type', choices=utils.get_choices(('Virtual', 'Physical'))) description = TextAreaField('Description') + machine_type_id = SelectField('Machine Type') item_id = SelectField('Item', coerce=utils.coerce_to_str_or_none) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.machine_type_id.choices = utils.get_model_choices(models.MachineType) self.item_id.choices = utils.get_model_choices(models.Item, allow_none=True, attr='ics_id') diff --git a/app/network/views.py b/app/network/views.py index a4e8da9509ba135dfe6df157ebfeeacfea70334f..10f16e5d59d3c953ff73ca409e61afaccf394a08 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -48,7 +48,7 @@ def create_host(): if form.validate_on_submit(): network_id = form.network_id.data host = models.Host(name=form.name.data, - type=form.type.data, + machine_type_id=form.machine_type_id.data, description=form.description.data or None, item_id=form.item_id.data) # The total number of tags will always be quite small @@ -93,7 +93,7 @@ def edit_host(name): form = HostForm(request.form, obj=host) if form.validate_on_submit(): host.name = form.name.data - host.type = form.type.data + host.machine_type_id = form.machine_type_id.data host.item_id = form.item_id.data host.description = form.description.data or None current_app.logger.debug(f'Trying to update: {host!r}') @@ -113,7 +113,7 @@ def edit_host(name): @login_groups_accepted('admin', 'create') def create_interface(hostname): host = models.Host.query.filter_by(name=hostname).first_or_404() - random_mac = host.type == 'Virtual' + random_mac = host.machine_type.name == 'Virtual' form = InterfaceForm(request.form, host_id=host.id, interface_name=host.name, random_mac=random_mac) if form.validate_on_submit(): @@ -280,7 +280,7 @@ def create_scope(): @login_required def retrieve_hosts(): data = [(host.name, - host.type, + str(host.machine_type), host.description, interface.name, interface.ip, diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js index 62f5a591365413f8841e9ad1616002523a9eacbc..3574fffa43948b2f3f8e0ff6b45677704b938874 100644 --- a/app/static/js/hosts.js +++ b/app/static/js/hosts.js @@ -43,12 +43,22 @@ $(document).ready(function() { set_default_ip(); }); - // Enable / disable item_id field depending on type + // Enable / disable item_id on edit host first page load + if( $("#editHostForm").length ) { + var machine_type = $("#machine_type_id option:selected").text(); + if( machine_type == "Physical" ) { + $("#item_id").prop("disabled", false); + } else { + $("#item_id").prop("disabled", true); + } + } + + // Enable / disable item_id field depending on machine_type // Item can only be assigned for physical hosts // And check / uncheck random_mac checkbox - $("#type").on('change', function() { - var host_type = $(this).val(); - if( host_type == "Physical" ) { + $("#machine_type_id").on('change', function() { + var machine_type = $("#machine_type_id option:selected").text(); + if( machine_type == "Physical" ) { $("#item_id").prop("disabled", false); $("#random_mac").prop("checked", false).change(); } else { diff --git a/app/templates/network/create_host.html b/app/templates/network/create_host.html index 1a2f35fce0c32fb6b0d072006604fe561a5483b3..515296ea80267a167d89e4e44bde9c5f9f2b37db 100644 --- a/app/templates/network/create_host.html +++ b/app/templates/network/create_host.html @@ -7,7 +7,7 @@ <form id="hostForm" method="POST"> {{ form.hidden_tag() }} {{ render_field(form.name, class_="text-lowercase") }} - {{ render_field(form.type) }} + {{ render_field(form.machine_type_id) }} {{ render_field(form.description) }} {{ render_field(form.item_id, disabled=True) }} {{ render_field(form.network_id) }} diff --git a/app/templates/network/edit_host.html b/app/templates/network/edit_host.html index fedffe3cd2e077c64aff0349f168a89899ec316a..6de72138aa6bd7ab871f8ea1c539aec031c3ca67 100644 --- a/app/templates/network/edit_host.html +++ b/app/templates/network/edit_host.html @@ -19,9 +19,9 @@ <form id="editHostForm" method="POST"> {{ form.hidden_tag() }} {{ render_field(form.name, class_="text-lowercase") }} - {{ render_field(form.type) }} + {{ render_field(form.machine_type_id) }} {{ render_field(form.description) }} - {{ render_field(form.item_id, disabled=True) }} + {{ render_field(form.item_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/view_host.html b/app/templates/network/view_host.html index 50ae3f96d71768616c5308fe132bfba952e288fc..bb3818748338a900bdceef1aa86506a06feb0680 100644 --- a/app/templates/network/view_host.html +++ b/app/templates/network/view_host.html @@ -19,9 +19,9 @@ <dl class="row"> <dt class="col-sm-3">Hostname</dt> <dd class="col-sm-9">{{ host.name }}</dd> - <dt class="col-sm-3">Type</dt> - <dd class="col-sm-9">{{ host.type }}</dd> - {% if host.type == 'Physical' %} + <dt class="col-sm-3">Machine Type</dt> + <dd class="col-sm-9">{{ host.machine_type }}</dd> + {% if host.machine_type.name == 'Physical' %} <dt class="col-sm-3">Item</dt> <dd class="col-sm-9">{{ link_to_item(host.item) }}</dd> {% endif %} diff --git a/migrations/versions/ac6b3c416b07_add_machine_type_table.py b/migrations/versions/ac6b3c416b07_add_machine_type_table.py new file mode 100644 index 0000000000000000000000000000000000000000..a2ff1cab60b3f02455104043ccad687e05f2cd25 --- /dev/null +++ b/migrations/versions/ac6b3c416b07_add_machine_type_table.py @@ -0,0 +1,52 @@ +"""Add machine_type table + +Revision ID: ac6b3c416b07 +Revises: dfd4eae61224 +Create Date: 2018-04-06 12:17:16.469046 + +""" +from alembic import op +import sqlalchemy as sa +import citext + + +# revision identifiers, used by Alembic. +revision = 'ac6b3c416b07' +down_revision = 'dfd4eae61224' +branch_labels = None +depends_on = None + + +def upgrade(): + machine_type = op.create_table( + 'machine_type', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', citext.CIText(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_machine_type')), + sa.UniqueConstraint('name', name=op.f('uq_machine_type_name')) + ) + # WARNING! If the database is not emppty, we can't set the machine_type_id to nullable=False before adding a value! + op.add_column('host', sa.Column('machine_type_id', sa.Integer(), nullable=True)) + op.create_foreign_key(op.f('fk_host_machine_type_id_machine_type'), 'host', 'machine_type', ['machine_type_id'], ['id']) + # Create the Physical and Virtual machine types + op.execute(machine_type.insert().values([ + {'id': 1, 'name': 'Physical'}, + {'id': 2, 'name': 'Virtual'}, + ])) + # Fill the host machine_type_id based on the value from the type column + host = sa.sql.table('host', sa.sql.column('machine_type_id'), sa.sql.column('type')) + op.execute(host.update().where(host.c.type == 'Physical').values(machine_type_id=1)) + op.execute(host.update().where(host.c.type == 'Virtual').values(machine_type_id=2)) + op.drop_column('host', 'type') + # Add the nullable=False constraint + op.alter_column('host', 'machine_type_id', nullable=False) + + +def downgrade(): + op.add_column('host', sa.Column('type', sa.TEXT(), autoincrement=False, nullable=True)) + host = sa.sql.table('host', sa.sql.column('machine_type_id'), sa.sql.column('type')) + op.execute(host.update().where(host.c.machine_type_id == 1).values(type='Physical')) + op.execute(host.update().where(host.c.machine_type_id == 2).values(type='Virtual')) + op.drop_constraint(op.f('fk_host_machine_type_id_machine_type'), 'host', type_='foreignkey') + op.drop_column('host', 'machine_type_id') + op.drop_table('machine_type') diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 05575ffca4382c7ef1ffcd13fd9a51d7d25d429c..597f36e64b71d2e5f1f49fbbecb06ab0ffe1a312 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -27,6 +27,7 @@ register(factories.ItemFactory) register(factories.NetworkScopeFactory) register(factories.NetworkFactory) register(factories.InterfaceFactory) +register(factories.MachineTypeFactory) register(factories.HostFactory) register(factories.MacFactory) register(factories.DomainFactory) diff --git a/tests/functional/factories.py b/tests/functional/factories.py index a5fc4d375a454529a711f91de07b6435d06d6d42..b101dc6d39b8f5db5f5a61c7775e5cc1899ac428 100644 --- a/tests/functional/factories.py +++ b/tests/functional/factories.py @@ -151,6 +151,15 @@ class InterfaceFactory(factory.alchemy.SQLAlchemyModelFactory): user = factory.SubFactory(UserFactory) +class MachineTypeFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = models.MachineType + sqlalchemy_session = common.Session + sqlalchemy_session_persistence = 'commit' + + name = factory.Sequence(lambda n: f'Type{n}') + + class HostFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: model = models.Host @@ -159,6 +168,7 @@ class HostFactory(factory.alchemy.SQLAlchemyModelFactory): name = factory.Sequence(lambda n: f'host{n}') user = factory.SubFactory(UserFactory) + machine_type = factory.SubFactory(MachineTypeFactory) class MacFactory(factory.alchemy.SQLAlchemyModelFactory): diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 7faab03e208bd68c7ac524ae774d546c10085329..50646c9a249a0e1c91651bcc178999ad9188d601 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -758,16 +758,22 @@ def test_get_hosts(client, host_factory, readonly_token): check_input_is_subset_of_response(response, (host1.to_dict(), host2.to_dict())) -def test_create_host(client, item_factory, user_token): +def test_create_host(client, item_factory, machine_type_factory, user_token): item = item_factory() - # check that name is mandatory + machine_type = machine_type_factory(name='Virtual') + # check that name and machine_type are mandatory response = post(client, f'{API_URL}/network/hosts', data={}, token=user_token) check_response_message(response, "Missing mandatory field 'name'", 422) + response = post(client, f'{API_URL}/network/hosts', data={'name': 'myhost'}, token=user_token) + check_response_message(response, "Missing mandatory field 'machine_type'", 422) + response = post(client, f'{API_URL}/network/hosts', data={'machine_type': 'Physical'}, token=user_token) + check_response_message(response, "Missing mandatory field 'name'", 422) - data = {'name': 'my-hostname'} + data = {'name': 'my-hostname', + 'machine_type': machine_type.name} response = post(client, f'{API_URL}/network/hosts', data=data, token=user_token) assert response.status_code == 201 - assert {'id', 'name', 'type', 'description', + assert {'id', 'name', 'machine_type', 'description', 'item', 'interfaces', 'created_at', 'updated_at', 'user'} == set(response.json.keys()) assert response.json['name'] == data['name'] @@ -778,6 +784,7 @@ def test_create_host(client, item_factory, user_token): # Check that we can pass an item_id data2 = {'name': 'another-hostname', + 'machine_type': machine_type.name, 'item_id': item.id} response = post(client, f'{API_URL}/network/hosts', data=data2, token=user_token) assert response.status_code == 201 @@ -786,8 +793,10 @@ def test_create_host(client, item_factory, user_token): assert models.Host.query.count() == 2 -def test_create_host_as_consultant(client, item_factory, consultant_token): - data = {'name': 'my-hostname'} +def test_create_host_as_consultant(client, item_factory, machine_type_factory, consultant_token): + machine_type = machine_type_factory() + data = {'name': 'my-hostname', + 'machine_type': machine_type.name} response = post(client, f'{API_URL}/network/hosts', data=data, token=consultant_token) assert response.status_code == 201