From ce031db6ddef5c30b0fed35efab95df3f1da55c1 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Sun, 22 Apr 2018 21:48:55 +0200 Subject: [PATCH] Add stack_member field to item This is to be used for stack of switches only - a stack_member is linked to an host (device_type has to be "Switch") - a stack_member is an integer between 0 and 9 (included) or can be None - the couple (host_id, stack_member) must be unique JIRA INFRA-267 --- app/inventory/forms.py | 7 ++- app/inventory/views.py | 53 ++++++++++++++++++- app/models.py | 20 +++++++ app/network/forms.py | 14 +---- app/static/js/create_item.js | 8 +++ app/static/js/csentry.js | 32 +++++++++++ app/static/js/edit_item.js | 11 ++++ app/static/js/networks.js | 9 ---- app/templates/_helpers.html | 14 +++++ app/templates/inventory/create_item.html | 3 +- app/templates/inventory/edit_item.html | 7 ++- app/templates/inventory/view_item.html | 4 ++ app/templates/network/view_host.html | 6 ++- app/utils.py | 2 +- app/validators.py | 14 ++++- ...33_add_stack_member_field_to_item_table.py | 34 ++++++++++++ tests/functional/test_api.py | 2 +- 17 files changed, 210 insertions(+), 30 deletions(-) create mode 100644 app/static/js/edit_item.js create mode 100644 migrations/versions/573560351033_add_stack_member_field_to_item_table.py diff --git a/app/inventory/forms.py b/app/inventory/forms.py index a6fe0b5..1143efc 100644 --- a/app/inventory/forms.py +++ b/app/inventory/forms.py @@ -11,7 +11,8 @@ This module defines the inventory blueprint forms. """ from wtforms import SelectField, StringField, IntegerField, TextAreaField, validators from ..helpers import CSEntryForm -from ..validators import Unique, RegexpList, ICS_ID_RE, MAC_ADDRESS_RE +from ..validators import (Unique, RegexpList, ICS_ID_RE, MAC_ADDRESS_RE, + NoValidateSelectField) from .. import utils, models @@ -35,6 +36,10 @@ class ItemForm(CSEntryForm): 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) + stack_member = NoValidateSelectField( + 'Stack member', + coerce=utils.coerce_to_str_or_none, + choices=[]) mac_addresses = StringField( 'MAC addresses', description='space separated list of MAC addresses', diff --git a/app/inventory/views.py b/app/inventory/views.py index 9290fcc..7095eaf 100644 --- a/app/inventory/views.py +++ b/app/inventory/views.py @@ -131,7 +131,8 @@ def create_item(): location_id=form.location_id.data, status_id=form.status_id.data, parent_id=form.parent_id.data, - host_id=form.host_id.data) + host_id=form.host_id.data, + stack_member=form.stack_member.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) @@ -180,6 +181,14 @@ def edit_item(ics_id): item.ics_id = form.ics_id.data item.serial_number = form.serial_number.data item.quantity = form.quantity.data + # When a field is disabled, it's value is not passed to the request + # We don't use request.form.get('stack_member', None) to let the coerce + # function of the field properly convert the value + if 'stack_member' in request.form: + item.stack_member = form.stack_member.data + else: + # Field is disabled, force it to None + item.stack_member = None for key in ('manufacturer_id', 'model_id', 'location_id', 'status_id', 'parent_id', 'host_id'): setattr(item, key, getattr(form, key).data) @@ -298,3 +307,45 @@ def update_favorites(kind): def scanner(): """Render the scanner setup codes""" return render_template('inventory/scanner.html') + + +@bp.route('/_retrieve_free_stack_members/<host_id>') +@login_required +def retrieve_free_stack_members(host_id): + """Return as json the free stack members numbers for the given host + + If ics_id is passed in the query string, the member will be added to + the list (if it exists) + + Used to populate dynamically the stack_member field in the create and + edit item forms + """ + disabled_data = { + 'stack_members': [], + 'selected_member': None, + 'disabled': True, + } + try: + host = models.Host.query.get(host_id) + except sa.exc.DataError: + # In case of unknown host_id or if host_id is None + current_app.logger.debug(f'Invalid host_id: {host_id}') + return jsonify(data=disabled_data) + if str(host.device_type) != 'Switch': + return jsonify(data=disabled_data) + members = host.free_stack_members() + selected_member = 'None' + ics_id = request.args.get('ics_id', None) + item = models.Item.query.filter_by(ics_id=ics_id).first() + if item is None: + current_app.logger.debug(f'Unknown ics_id: {ics_id}') + else: + if item.stack_member is not None: + members.append(item.stack_member) + members.sort() + selected_member = item.stack_member + members = ['None'] + members + data = {'stack_members': members, + 'selected_member': selected_member, + 'disabled': False} + return jsonify(data=data) diff --git a/app/models.py b/app/models.py index b3b55e4..ceb3e77 100644 --- a/app/models.py +++ b/app/models.py @@ -348,6 +348,7 @@ class Item(CreatedMixin, db.Model): 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')) + stack_member = db.Column(db.SmallInteger) manufacturer = db.relationship('Manufacturer', back_populates='items') model = db.relationship('Model', back_populates='items') @@ -357,6 +358,11 @@ class Item(CreatedMixin, db.Model): macs = db.relationship('Mac', backref='item') comments = db.relationship('ItemComment', backref='item') + __table_args__ = ( + sa.CheckConstraint('stack_member >= 0 AND stack_member <=9', name='stack_member_range'), + sa.UniqueConstraint(host_id, stack_member, name='uq_item_host_id_stack_member'), + ) + def __init__(self, **kwargs): # Automatically convert manufacturer/model/location/status to an # instance of their class if passed as a string @@ -393,6 +399,7 @@ class Item(CreatedMixin, db.Model): 'children': [str(child) for child in self.children], 'macs': [str(mac) for mac in self.macs], 'host': utils.format_field(self.host), + 'stack_member': utils.format_field(self.stack_member), 'history': self.history(), 'comments': [str(comment) for comment in self.comments], }) @@ -638,6 +645,19 @@ class Host(CreatedMixin, db.Model): raise ValidationError('Interface name shall match [a-z0-9\-]{2,20}') return lower_string + def stack_members(self): + """Return all items part of the stack sorted by stack member number""" + members = [item for item in self.items if item.stack_member is not None] + return sorted(members, key=lambda x: x.stack_member) + + def stack_members_numbers(self): + """Return the list of stack member numbers""" + return [item.stack_member for item in self.stack_members()] + + def free_stack_members(self): + """Return the list of free stack member numbers""" + return [nb for nb in range(0, 10) if nb not in self.stack_members_numbers()] + def to_dict(self): d = super().to_dict() d.update({ diff --git a/app/network/forms.py b/app/network/forms.py index 497174d..ea87f0f 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -15,7 +15,7 @@ from wtforms import (SelectField, StringField, TextAreaField, IntegerField, SelectMultipleField, BooleanField, validators) from ..helpers import CSEntryForm from ..validators import (Unique, RegexpList, IPNetwork, HOST_NAME_RE, - VLAN_NAME_RE, MAC_ADDRESS_RE) + VLAN_NAME_RE, MAC_ADDRESS_RE, NoValidateSelectField) from .. import utils, models @@ -58,18 +58,6 @@ def ip_in_network(form, field): raise validators.ValidationError(f'IP address {ip} is not in range {network.first} - {network.last}') -class NoValidateSelectField(SelectField): - """SelectField with no choices validation - - By default a SelectField tries to validate the selected value - against the list of choices. This is not possible when the choices - are dynamically created on the browser side. - """ - - def pre_validate(self, form): - pass - - class DomainForm(CSEntryForm): name = StringField('Name', validators=[validators.InputRequired(), diff --git a/app/static/js/create_item.js b/app/static/js/create_item.js index 008bbcd..2d7a1a5 100644 --- a/app/static/js/create_item.js +++ b/app/static/js/create_item.js @@ -10,6 +10,9 @@ $(document).ready(function() { // Focus to ICS id when loading the page $("#ics_id").focus(); + // Populate the stack member field linked to the host on first page load + update_stack_member(); + // Prevent enter key to submit the form when scanning a label // and remove the ICS:ics_id: prefix $("#ics_id").keydown(function(event) { @@ -38,4 +41,9 @@ $(document).ready(function() { $("select").val(''); }); + // Update the stack member field linked to the host when changing it + $("#host_id").on('change', function() { + update_stack_member(); + }); + }); diff --git a/app/static/js/csentry.js b/app/static/js/csentry.js index 9bf4c77..e2721ae 100644 --- a/app/static/js/csentry.js +++ b/app/static/js/csentry.js @@ -1,3 +1,35 @@ +// Function to dynamically update a select field +function update_selectfield(field_id, data, selected_value) { + var $field = $(field_id); + $field.empty(); + $.map(data, function(option, index) { + if( option == "None" ) { + var text = ""; + } else { + var text = option; + } + $field.append($("<option></option>").attr("value", option).text(text)); + }); + $field.val(selected_value); +} + +// Function to populate dynamically the stack_member field +// in the create and edit item forms +function update_stack_member() { + // Retrieve free stack members + var host_id = $("#host_id").val(); + $.getJSON( + $SCRIPT_ROOT + "/inventory/_retrieve_free_stack_members/" + host_id, + { + ics_id: $("#ics_id").val() + }, + function(json) { + update_selectfield("#stack_member", json.data.stack_members, json.data.selected_member); + $("#stack_member").prop("disabled", json.data.disabled); + } + ); +} + $(document).ready(function() { // When an invalid input was submitted, the server diff --git a/app/static/js/edit_item.js b/app/static/js/edit_item.js new file mode 100644 index 0000000..b1fdc39 --- /dev/null +++ b/app/static/js/edit_item.js @@ -0,0 +1,11 @@ +$(document).ready(function() { + + // Populate the stack member field linked to the host on first page load + update_stack_member(); + + // Update the stack member field linked to the host when changing it + $("#host_id").on('change', function() { + update_stack_member(); + }); + +}); diff --git a/app/static/js/networks.js b/app/static/js/networks.js index 3ad026b..986801f 100644 --- a/app/static/js/networks.js +++ b/app/static/js/networks.js @@ -1,14 +1,5 @@ $(document).ready(function() { - function update_selectfield(field_id, data, selected_value) { - var $field = $(field_id); - $field.empty(); - $.map(data, function(option, index) { - $field.append($("<option></option>").attr("value", option).text(option)); - }); - $field.val(selected_value); - } - function update_scope_defaults() { // Retrieve available vlans, subnet prefixes and default domain // for the selected network scope and update the linked select fields diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html index 7e78556..5bb36c4 100644 --- a/app/templates/_helpers.html +++ b/app/templates/_helpers.html @@ -20,6 +20,20 @@ {% endfor %} {%- endmacro %} +{% macro link_to_stack_member(item) -%} + {% if item.stack_member is not none %} + <a href="{{ url_for('inventory.view_item', ics_id=item.ics_id) }}">{{ item.ics_id }} ({{ item.stack_member }})</a> + {% else %} + <a href="{{ url_for('inventory.view_item', ics_id=item.ics_id) }}">{{ item.ics_id }}</a> + {% endif %} +{%- endmacro %} + +{% macro link_to_stack_members(items) -%} + {% for item in items %} + {{ link_to_stack_member(item) }} + {% endfor %} +{%- endmacro %} + {% macro render_field(field) -%} {% set field_class = kwargs.pop('class_', '') + ' form-control' %} {% if field.errors %} diff --git a/app/templates/inventory/create_item.html b/app/templates/inventory/create_item.html index eac63e1..ec13c71 100644 --- a/app/templates/inventory/create_item.html +++ b/app/templates/inventory/create_item.html @@ -6,7 +6,7 @@ {% block items_main %} <div class="row"> <div class="col-sm-11"> - <form id="itemForm" method="POST"> + <form id="createItemForm" method="POST"> {{ form.hidden_tag() }} {{ render_field(form.ics_id) }} {{ render_field(form.serial_number) }} @@ -17,6 +17,7 @@ {{ render_field(form.status_id) }} {{ render_field(form.parent_id) }} {{ render_field(form.host_id) }} + {{ render_field(form.stack_member) }} {{ 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 3797048..724be0c 100644 --- a/app/templates/inventory/edit_item.html +++ b/app/templates/inventory/edit_item.html @@ -14,7 +14,7 @@ {% block items_main %} - <form id="itemForm" method="POST"> + <form id="editItemForm" method="POST"> {{ form.hidden_tag() }} {% if form.ics_id.data.startswith(config['TEMPORARY_ICS_ID']) or form.ics_id.errors %} {{ render_field(form.ics_id) }} @@ -29,6 +29,7 @@ {{ render_field(form.status_id) }} {{ render_field(form.parent_id) }} {{ render_field(form.host_id) }} + {{ render_field(form.stack_member) }} {{ render_field(form.mac_addresses) }} <div class="form-group row"> <div class="col-sm-10"> @@ -37,3 +38,7 @@ </div> </form> {%- endblock %} + +{% block csentry_scripts %} + <script src="{{ url_for('static', filename='js/edit_item.js') }}"></script> +{% endblock %} diff --git a/app/templates/inventory/view_item.html b/app/templates/inventory/view_item.html index c6e1814..93b5069 100644 --- a/app/templates/inventory/view_item.html +++ b/app/templates/inventory/view_item.html @@ -42,6 +42,10 @@ <dt class="col-sm-3">Host</dt> <dd class="col-sm-9">{{ link_to_host(item.host) }}</dd> {% endif %} + {% if item.stack_member is not none %} + <dt class="col-sm-3">Stack member</dt> + <dd class="col-sm-9">{{ item.stack_member }}</dd> + {% endif %} {% for mac in item.macs %} {% set macloop = loop %} <dt class="col-sm-3">MAC Address{{ loop.index }}</dt> diff --git a/app/templates/network/view_host.html b/app/templates/network/view_host.html index 6d60c74..c07f214 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_items, delete_button_with_confirmation %} +{% from "_helpers.html" import link_to_items, link_to_stack_members, delete_button_with_confirmation %} {% block title %}View Host - CSEntry{% endblock %} @@ -25,6 +25,10 @@ <dt class="col-sm-3">Items</dt> <dd class="col-sm-9">{{ link_to_items(host.items) }}</dd> {% endif %} + {% if host.stack_members() %} + <dt class="col-sm-3">Stack Members</dt> + <dd class="col-sm-9">{{ link_to_stack_members(host.stack_members()) }}</dd> + {% endif %} <dt class="col-sm-3">Description</dt> <dd class="col-sm-9">{{ host.description }}</dd> </dl> diff --git a/app/utils.py b/app/utils.py index a8f7896..86d9806 100644 --- a/app/utils.py +++ b/app/utils.py @@ -176,7 +176,7 @@ def lowercase_field(value): # To pass wtforms validation, the value returned must be part of choices def coerce_to_str_or_none(value): """Convert '', None and 'None' to None""" - if not value or value == 'None': + if value in ('', 'None') or value is None: return None return str(value) diff --git a/app/validators.py b/app/validators.py index 6c8ae87..c73c3d9 100644 --- a/app/validators.py +++ b/app/validators.py @@ -12,7 +12,7 @@ This module defines extra field validators import ipaddress import re import sqlalchemy as sa -from wtforms import ValidationError +from wtforms import ValidationError, SelectField ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}') HOST_NAME_RE = re.compile('^[a-z0-9\-]{2,20}$') @@ -20,6 +20,18 @@ VLAN_NAME_RE = re.compile('^[A-Za-z0-9\-]{3,25}$') MAC_ADDRESS_RE = re.compile('^(?:[0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}$') +class NoValidateSelectField(SelectField): + """SelectField with no choices validation + + By default a SelectField tries to validate the selected value + against the list of choices. This is not possible when the choices + are dynamically created on the browser side. + """ + + def pre_validate(self, form): + pass + + class IPNetwork: """Validates an IP network. diff --git a/migrations/versions/573560351033_add_stack_member_field_to_item_table.py b/migrations/versions/573560351033_add_stack_member_field_to_item_table.py new file mode 100644 index 0000000..e3a1b33 --- /dev/null +++ b/migrations/versions/573560351033_add_stack_member_field_to_item_table.py @@ -0,0 +1,34 @@ +"""Add stack_member field to item table + +Revision ID: 573560351033 +Revises: 7ffb5fbbd0f0 +Create Date: 2018-04-20 14:24:07.772005 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '573560351033' +down_revision = '7ffb5fbbd0f0' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('item', sa.Column('stack_member', sa.SmallInteger(), nullable=True)) + op.create_unique_constraint(op.f('uq_item_host_id_stack_member'), 'item', ['host_id', 'stack_member']) + op.create_check_constraint( + op.f('ck_item_stack_member_range'), + 'item', + 'stack_member >= 0 AND stack_member <=9' + ) + op.add_column('item_version', sa.Column('stack_member', sa.SmallInteger(), autoincrement=False, nullable=True)) + + +def downgrade(): + op.drop_column('item_version', 'stack_member') + op.drop_constraint(op.f('uq_item_host_id_stack_member'), 'item', type_='unique') + op.drop_constraint(op.f('ck_item_stack_member_range'), 'item', type_='check') + op.drop_column('item', 'stack_member') diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 33835ce..2a71aa0 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -217,7 +217,7 @@ def test_create_item(client, user_token): assert response.status_code == 201 assert {'id', 'ics_id', 'serial_number', 'manufacturer', 'model', 'quantity', 'location', 'status', 'parent', 'children', 'macs', 'history', 'host', - 'updated_at', 'created_at', 'user', 'comments'} == set(response.json.keys()) + 'stack_member', 'updated_at', 'created_at', 'user', 'comments'} == set(response.json.keys()) assert response.json['serial_number'] == '123456' # Check that serial_number doesn't have to be unique -- GitLab