diff --git a/app/inventory/forms.py b/app/inventory/forms.py index a6fe0b57c3037dec521f8f4f3acef88fd457cc91..1143efca2818714c07a6183f387daf5b6e19061c 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 9290fcc21674084c89e2b08af6e7fcc5721c9a88..7095eafc275c7c23be7474686c1bd75662390754 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 b3b55e4493aa40d1e82978f57ee5a10038b57254..ceb3e7771cb5948aef11f1f70fdf6bb21e8ecddf 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 497174d0d3362c4a136f81908bbdebb18409d21d..ea87f0ff92d05787caba5055624c370d9ee82aa9 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 008bbcd28c9b0d32ba8658b3252a259c35b361fd..2d7a1a5e55bbe35ab6ab4b443f2f365b2e1ac6f4 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 9bf4c7731f20d8a00b76ab40b1c81594c762a92c..e2721ae47e083a392b22ddf22565463112a2f400 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 0000000000000000000000000000000000000000..b1fdc39defdb58ab55c340ec61e3ef739007d3a3 --- /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 3ad026bedd2357789c53e0b0a77bef5c173d5775..986801fe64df6a61d286e6f6e81e3268297ca98c 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 7e78556c5e5b044fcb23a9bf0dc14cebeb20cf21..5bb36c4759083019ccbc57570deb98cf1caa980b 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 eac63e1b9f55129fecf50e8ee0dd6cc261dca0d5..ec13c714c9d779991e7d2b8782152f861cdb079e 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 3797048b9f8b098082976311c32500bd13dba784..724be0c7b9ef08304eae04697c07c8ab3a1c3c8b 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 c6e1814fb71cf2aae00aca657520816ed6b2feef..93b5069341886452267828e1c22329bba4aa0ded 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 6d60c7475ebab76caf9ef2be5e203875a7de3259..c07f21409ca67f3b06b88af456613cb6848bc39b 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 a8f7896e84893626ff5a99ff18ffadfcabdd74a4..86d9806bc7ec1339978d86babe4e32edcfff106e 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 6c8ae870c02983efb7d2d7c6421fb019b442c439..c73c3d9db91b8154871521f98bc8b47e00e0939c 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 0000000000000000000000000000000000000000..e3a1b33e68fc5ec0977da60f929191329ea263b7 --- /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 33835cef9db8fd5c9270d215f6e1020a78c27741..2a71aa0bac372b1a409d3924f35b123b59be1c93 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