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