diff --git a/app/api/inventory.py b/app/api/inventory.py index 8827024e2d0320474b164b08e307a6449beb9fb1..abc2f0510710029bad28a0af232a64e027dae9af 100644 --- a/app/api/inventory.py +++ b/app/api/inventory.py @@ -72,6 +72,7 @@ def create_item(): :jsonparam location: (optional) name of the location :jsonparam status: (optional) name of the status :jsonparam parent_id: (optional) parent id + :jsonparam host_id: (optional) host id """ # People should assign an ICS id to a serial number when creating # an item so ics_id should also be a mandatory field. diff --git a/app/api/network.py b/app/api/network.py index d8e4239e72f0db5c8e0b5a181afd42595ec893ec..2079f585515052971c50ba08740b7e0531f1bd82 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -143,7 +143,7 @@ def create_host(): :jsonparam name: hostname :jsonparam device_type: Physical|Virtual|... :jsonparam description: (optional) description - :jsonparam item_id: (optional) linked item primary key + :jsonparam items: (optional) list of items ICS id linked to the host """ return create_generic_model(models.Host, mandatory_fields=('name', 'device_type')) diff --git a/app/inventory/forms.py b/app/inventory/forms.py index 1f40e0dc49828dfc8915c9cb06ed5dfcda67af79..a6fe0b57c3037dec521f8f4f3acef88fd457cc91 100644 --- a/app/inventory/forms.py +++ b/app/inventory/forms.py @@ -34,6 +34,7 @@ class ItemForm(CSEntryForm): location_id = SelectField('Location', coerce=utils.coerce_to_str_or_none) status_id = SelectField('Status', coerce=utils.coerce_to_str_or_none) parent_id = SelectField('Parent', coerce=utils.coerce_to_str_or_none) + host_id = SelectField('Host', coerce=utils.coerce_to_str_or_none) mac_addresses = StringField( 'MAC addresses', description='space separated list of MAC addresses', @@ -47,6 +48,7 @@ class ItemForm(CSEntryForm): self.location_id.choices = utils.get_model_choices(models.Location, allow_none=True) self.status_id.choices = utils.get_model_choices(models.Status, allow_none=True) self.parent_id.choices = utils.get_model_choices(models.Item, allow_none=True, attr='ics_id') + self.host_id.choices = utils.get_model_choices(models.Host, allow_none=True) class CommentForm(CSEntryForm): diff --git a/app/inventory/views.py b/app/inventory/views.py index 053577f2ec68742fe5599af43dbedce9a14496a4..9290fcc21674084c89e2b08af6e7fcc5721c9a88 100644 --- a/app/inventory/views.py +++ b/app/inventory/views.py @@ -130,7 +130,8 @@ def create_item(): model_id=form.model_id.data, location_id=form.location_id.data, status_id=form.status_id.data, - parent_id=form.parent_id.data) + parent_id=form.parent_id.data, + host_id=form.host_id.data) item.macs = [models.Mac(address=address) for address in form.mac_addresses.data.split()] current_app.logger.debug(f'Trying to create: {item!r}') db.session.add(item) @@ -179,7 +180,8 @@ def edit_item(ics_id): item.ics_id = form.ics_id.data item.serial_number = form.serial_number.data item.quantity = form.quantity.data - for key in ('manufacturer_id', 'model_id', 'location_id', 'status_id', 'parent_id'): + for key in ('manufacturer_id', 'model_id', 'location_id', 'status_id', + 'parent_id', 'host_id'): setattr(item, key, getattr(form, key).data) new_addresses = form.mac_addresses.data.split() # Delete the MAC addresses that have been removed diff --git a/app/models.py b/app/models.py index 9505953989f326e018711d47649daa977a31082c..b3b55e4493aa40d1e82978f57ee5a10038b57254 100644 --- a/app/models.py +++ b/app/models.py @@ -347,6 +347,7 @@ class Item(CreatedMixin, db.Model): location_id = db.Column(db.Integer, db.ForeignKey('location.id')) status_id = db.Column(db.Integer, db.ForeignKey('status.id')) parent_id = db.Column(db.Integer, db.ForeignKey('item.id')) + host_id = db.Column(db.Integer, db.ForeignKey('host.id')) manufacturer = db.relationship('Manufacturer', back_populates='items') model = db.relationship('Model', back_populates='items') @@ -354,7 +355,6 @@ class Item(CreatedMixin, db.Model): status = db.relationship('Status', back_populates='items') children = db.relationship('Item', backref=db.backref('parent', remote_side=[id])) macs = db.relationship('Mac', backref='item') - host = db.relationship('Host', uselist=False, backref='item') comments = db.relationship('ItemComment', backref='item') def __init__(self, **kwargs): @@ -392,6 +392,7 @@ class Item(CreatedMixin, db.Model): 'parent': utils.format_field(self.parent), 'children': [str(child) for child in self.children], 'macs': [str(mac) for mac in self.macs], + 'host': utils.format_field(self.host), 'history': self.history(), 'comments': [str(comment) for comment in self.comments], }) @@ -609,14 +610,18 @@ class Host(CreatedMixin, db.Model): name = db.Column(db.Text, nullable=False, unique=True) description = db.Column(db.Text) device_type_id = db.Column(db.Integer, db.ForeignKey('device_type.id'), nullable=False) - item_id = db.Column(db.Integer, db.ForeignKey('item.id')) interfaces = db.relationship('Interface', backref='host') + items = db.relationship('Item', backref='host') def __init__(self, **kwargs): # Automatically convert device_type as an instance of its class if passed as a string if 'device_type' in kwargs: kwargs['device_type'] = utils.convert_to_model(kwargs['device_type'], DeviceType) + # Automatically convert items to a list of instances if passed as a list of ics_id + if 'items' in kwargs: + kwargs['items'] = [utils.convert_to_model(item, Item, filter='ics_id') + for item in kwargs['items']] super().__init__(**kwargs) def __str__(self): @@ -639,7 +644,7 @@ class Host(CreatedMixin, db.Model): 'name': self.name, 'device_type': str(self.device_type), 'description': self.description, - 'item': utils.format_field(self.item), + 'items': [str(item) for item in self.items], 'interfaces': [str(interface) for interface in self.interfaces], }) return d diff --git a/app/network/forms.py b/app/network/forms.py index ce2ab1b864eeb5d2822c8b0427c431c54bad448a..497174d0d3362c4a136f81908bbdebb18409d21d 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -126,12 +126,10 @@ class HostForm(CSEntryForm): filters=[utils.lowercase_field]) description = TextAreaField('Description') device_type_id = SelectField('Device Type') - item_id = SelectField('Item', coerce=utils.coerce_to_str_or_none) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.device_type_id.choices = utils.get_model_choices(models.DeviceType) - self.item_id.choices = utils.get_model_choices(models.Item, allow_none=True, attr='ics_id') class InterfaceForm(CSEntryForm): diff --git a/app/network/views.py b/app/network/views.py index 8cc66ef860e5958d5800ccf3a9dfd1901be06ecc..be5780efaa2d86156f378055495ef866804da8e4 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -49,8 +49,7 @@ def create_host(): network_id = form.network_id.data host = models.Host(name=form.name.data, device_type_id=form.device_type_id.data, - description=form.description.data or None, - item_id=form.item_id.data) + description=form.description.data or None) # The total number of tags will always be quite small # It's more efficient to retrieve all of them in one query # and do the filtering here @@ -94,7 +93,6 @@ def edit_host(name): if form.validate_on_submit(): host.name = form.name.data host.device_type_id = form.device_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}') try: diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js index 96df16c202fe237f4f406fc5280b6b620f12a435..9ec72da9f2548a1053c7fa598791014d37eec9c7 100644 --- a/app/static/js/hosts.js +++ b/app/static/js/hosts.js @@ -12,17 +12,12 @@ $(document).ready(function() { ); } - // Enable / disable item_id field depending on device_type - // Item can only be assigned for physical hosts // And check / uncheck random_mac checkbox function update_device_type_attributes() { var device_type = $("#device_type_id option:selected").text(); if( device_type.startsWith("Physical") ) { - $("#item_id").prop("disabled", false); $("#random_mac").prop("checked", false).change(); } else { - $("#item_id").val(""); - $("#item_id").prop("disabled", true); $("#random_mac").prop("checked", true).change(); } } diff --git a/app/templates/inventory/create_item.html b/app/templates/inventory/create_item.html index 19e3f94b2a2e9bbb590a1d8a78b0ea418b0f2a4d..eac63e1b9f55129fecf50e8ee0dd6cc261dca0d5 100644 --- a/app/templates/inventory/create_item.html +++ b/app/templates/inventory/create_item.html @@ -16,6 +16,7 @@ {{ render_field(form.location_id) }} {{ render_field(form.status_id) }} {{ render_field(form.parent_id) }} + {{ render_field(form.host_id) }} {{ render_field(form.mac_addresses) }} <div class="form-group row"> <div class="col-sm-10"> diff --git a/app/templates/inventory/edit_item.html b/app/templates/inventory/edit_item.html index 9a2b187c87a155b0ee317564396b383b3d47e66c..3797048b9f8b098082976311c32500bd13dba784 100644 --- a/app/templates/inventory/edit_item.html +++ b/app/templates/inventory/edit_item.html @@ -28,6 +28,7 @@ {{ render_field(form.location_id) }} {{ render_field(form.status_id) }} {{ render_field(form.parent_id) }} + {{ render_field(form.host_id) }} {{ render_field(form.mac_addresses) }} <div class="form-group row"> <div class="col-sm-10"> diff --git a/app/templates/network/create_host.html b/app/templates/network/create_host.html index f228a9d305d7ad40ce9bb217b57991ef4a8f895c..7b13bdbf02ee58db0a4829228115320e3ac44860 100644 --- a/app/templates/network/create_host.html +++ b/app/templates/network/create_host.html @@ -10,7 +10,6 @@ {{ render_field(form.name, class_="text-lowercase") }} {{ render_field(form.device_type_id) }} {{ render_field(form.description) }} - {{ render_field(form.item_id, disabled=True) }} {{ render_field(form.network_id) }} {{ render_field(form.ip) }} {{ render_field(form.random_mac) }} diff --git a/app/templates/network/edit_host.html b/app/templates/network/edit_host.html index fb4c2cd72d10c71536de67b7a38db128a55213b3..866f12a606991e7514c9e38850b19a0323528a22 100644 --- a/app/templates/network/edit_host.html +++ b/app/templates/network/edit_host.html @@ -21,7 +21,6 @@ {{ render_field(form.name, class_="text-lowercase") }} {{ render_field(form.device_type_id) }} {{ render_field(form.description) }} - {{ 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 6da69299d49bd8eb491dbeb9de1803ac8e6b81aa..6d60c7475ebab76caf9ef2be5e203875a7de3259 100644 --- a/app/templates/network/view_host.html +++ b/app/templates/network/view_host.html @@ -1,5 +1,5 @@ {% extends "network/hosts.html" %} -{% from "_helpers.html" import link_to_item, delete_button_with_confirmation %} +{% from "_helpers.html" import link_to_items, delete_button_with_confirmation %} {% block title %}View Host - CSEntry{% endblock %} @@ -21,9 +21,9 @@ <dd class="col-sm-9">{{ host.name }}</dd> <dt class="col-sm-3">Device Type</dt> <dd class="col-sm-9">{{ host.device_type }}</dd> - {% if host.device_type.name == 'Physical' %} - <dt class="col-sm-3">Item</dt> - <dd class="col-sm-9">{{ link_to_item(host.item) }}</dd> + {% if host.items %} + <dt class="col-sm-3">Items</dt> + <dd class="col-sm-9">{{ link_to_items(host.items) }}</dd> {% endif %} <dt class="col-sm-3">Description</dt> <dd class="col-sm-9">{{ host.description }}</dd> diff --git a/migrations/versions/7ffb5fbbd0f0_allow_to_associate_several_items_to_one_.py b/migrations/versions/7ffb5fbbd0f0_allow_to_associate_several_items_to_one_.py new file mode 100644 index 0000000000000000000000000000000000000000..a19dea20c42ce872a1634b2e027ee395776e8284 --- /dev/null +++ b/migrations/versions/7ffb5fbbd0f0_allow_to_associate_several_items_to_one_.py @@ -0,0 +1,48 @@ +"""Allow to associate several items to one host + +Revision ID: 7ffb5fbbd0f0 +Revises: e07c7bc870be +Create Date: 2018-04-20 12:01:25.242815 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7ffb5fbbd0f0' +down_revision = 'e07c7bc870be' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('item', sa.Column('host_id', sa.Integer(), nullable=True)) + op.create_foreign_key(op.f('fk_item_host_id_host'), 'item', 'host', ['host_id'], ['id']) + op.add_column('item_version', sa.Column('host_id', sa.Integer(), autoincrement=False, nullable=True)) + # Fill the item host_id based on the old host item_id value + conn = op.get_bind() + res = conn.execute('SELECT id, item_id FROM host WHERE item_id IS NOT NULL') + results = res.fetchall() + item = sa.sql.table('item', sa.sql.column('id'), sa.sql.column('host_id')) + for result in results: + op.execute(item.update().where(item.c.id == result[1]).values(host_id=result[0])) + # We can drop the item_id column now + op.drop_constraint('fk_host_item_id_item', 'host', type_='foreignkey') + op.drop_column('host', 'item_id') + + +def downgrade(): + op.add_column('host', sa.Column('item_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key('fk_host_item_id_item', 'host', 'item', ['item_id'], ['id']) + # Fill the host item_id based on the item host_id value + conn = op.get_bind() + res = conn.execute('SELECT id, host_id FROM item WHERE host_id IS NOT NULL') + results = res.fetchall() + host = sa.sql.table('host', sa.sql.column('id'), sa.sql.column('item_id')) + for result in results: + op.execute(host.update().where(host.c.id == result[1]).values(item_id=result[0])) + # Drop the unused columns + op.drop_column('item_version', 'host_id') + op.drop_constraint(op.f('fk_item_host_id_host'), 'item', type_='foreignkey') + op.drop_column('item', 'host_id') diff --git a/migrations/versions/ac6b3c416b07_add_machine_type_table.py b/migrations/versions/ac6b3c416b07_add_machine_type_table.py index a2ff1cab60b3f02455104043ccad687e05f2cd25..6adb797c8aa7e17e84e4f844050b46d18f550f9b 100644 --- a/migrations/versions/ac6b3c416b07_add_machine_type_table.py +++ b/migrations/versions/ac6b3c416b07_add_machine_type_table.py @@ -25,7 +25,7 @@ def upgrade(): 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! + # WARNING! If the database is not empty, 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 diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 1393553cbc44c4a7eb1c74daedd002a2c03c959e..33835cef9db8fd5c9270d215f6e1020a78c27741 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -216,7 +216,7 @@ def test_create_item(client, user_token): response = post(client, f'{API_URL}/inventory/items', data=data, token=user_token) assert response.status_code == 201 assert {'id', 'ics_id', 'serial_number', 'manufacturer', 'model', 'quantity', - 'location', 'status', 'parent', 'children', 'macs', 'history', + 'location', 'status', 'parent', 'children', 'macs', 'history', 'host', 'updated_at', 'created_at', 'user', 'comments'} == set(response.json.keys()) assert response.json['serial_number'] == '123456' @@ -237,6 +237,17 @@ def test_create_item(client, user_token): check_input_is_subset_of_response(response, (data, data, data2)) +def test_create_item_with_host_id(client, host_factory, user_token): + host = host_factory() + # Check that we can pass an host_id + data = {'serial_number': '123456', + 'host_id': host.id} + response = post(client, f'{API_URL}/inventory/items', data=data, token=user_token) + assert response.status_code == 201 + item = models.Item.query.filter_by(serial_number=data['serial_number']).first() + assert item.host_id == host.id + + def test_create_item_invalid_ics_id(client, user_token): for ics_id in ('foo', 'AAB1234', 'AZ02', 'WS007', 'AAA01'): data = {'serial_number': '123456', 'ics_id': ics_id} @@ -758,8 +769,7 @@ 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, device_type_factory, user_token): - item = item_factory() +def test_create_host(client, device_type_factory, user_token): device_type = device_type_factory(name='Virtual') # check that name and device_type are mandatory response = post(client, f'{API_URL}/network/hosts', data={}, token=user_token) @@ -774,7 +784,7 @@ def test_create_host(client, item_factory, device_type_factory, user_token): response = post(client, f'{API_URL}/network/hosts', data=data, token=user_token) assert response.status_code == 201 assert {'id', 'name', 'device_type', 'description', - 'item', 'interfaces', 'created_at', + 'items', 'interfaces', 'created_at', 'updated_at', 'user'} == set(response.json.keys()) assert response.json['name'] == data['name'] @@ -782,15 +792,23 @@ def test_create_host(client, item_factory, device_type_factory, user_token): response = post(client, f'{API_URL}/network/hosts', data=data, token=user_token) check_response_message(response, '(psycopg2.IntegrityError) duplicate key value violates unique constraint', 422) - # Check that we can pass an item_id - data2 = {'name': 'another-hostname', - 'device_type': device_type.name, - 'item_id': item.id} - response = post(client, f'{API_URL}/network/hosts', data=data2, token=user_token) - assert response.status_code == 201 + # check that the number of items created + assert models.Host.query.count() == 1 - # check that all items were created - assert models.Host.query.count() == 2 + +def test_create_host_with_items(client, item_factory, device_type_factory, user_token): + device_type = device_type_factory(name='Switch') + item1 = item_factory(ics_id='AAA001') + item2 = item_factory(ics_id='AAA002') + # Check that we can pass a list of items ics_id + data = {'name': 'my-switch', + 'device_type': device_type.name, + 'items': [item1.ics_id, item2.ics_id]} + response = post(client, f'{API_URL}/network/hosts', data=data, token=user_token) + assert response.status_code == 201 + host = models.Host.query.filter_by(name='my-switch').first() + assert models.Item.query.get(item1.id).host_id == host.id + assert models.Item.query.get(item2.id).host_id == host.id def test_create_host_as_consultant(client, item_factory, device_type_factory, consultant_token):