From 7327009c1d0807895d4e61f9e7125bf6e2a3e2d8 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Tue, 19 Dec 2017 16:16:52 +0100
Subject: [PATCH] Add UI to view/edit host and add/edit interface

---
 app/models.py                               |   1 +
 app/network/forms.py                        |  14 ++-
 app/network/views.py                        | 112 +++++++++++++++++++-
 app/static/js/hosts.js                      |  26 ++++-
 app/templates/base-fluid.html               |   2 +-
 app/templates/network/create_interface.html |  34 ++++++
 app/templates/network/edit_host.html        |  31 ++++++
 app/templates/network/edit_interface.html   |  37 +++++++
 app/templates/network/view_host.html        |  68 ++++++++++++
 9 files changed, 315 insertions(+), 10 deletions(-)
 create mode 100644 app/templates/network/create_interface.html
 create mode 100644 app/templates/network/edit_host.html
 create mode 100644 app/templates/network/edit_interface.html
 create mode 100644 app/templates/network/view_host.html

diff --git a/app/models.py b/app/models.py
index 2a64759..c32d22e 100644
--- a/app/models.py
+++ b/app/models.py
@@ -247,6 +247,7 @@ class Item(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):
diff --git a/app/network/forms.py b/app/network/forms.py
index c78c52d..c141eb3 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -70,6 +70,14 @@ class HostForm(CSEntryForm):
     type = SelectField('Type', choices=utils.get_choices(('Virtual', 'Physical')))
     description = TextAreaField('Description')
     item_id = SelectField('Item', coerce=utils.coerce_to_str_or_none)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.item_id.choices = utils.get_model_choices(models.Item, allow_none=True, attr='ics_id')
+
+
+class InterfaceForm(CSEntryForm):
+    host_id = SelectField('Host')
     network_id = SelectField('Network')
     # The list of IPs is dynamically created on the browser side
     # depending on the selected network
@@ -87,7 +95,7 @@ class HostForm(CSEntryForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.item_id.choices = utils.get_model_choices(models.Item, allow_none=True, attr='ics_id')
+        self.host_id.choices = utils.get_model_choices(models.Host)
         if current_user.is_admin:
             network_query = models.Network.query
         else:
@@ -96,3 +104,7 @@ class HostForm(CSEntryForm):
                                                           attr='vlan_name', query=network_query)
         self.mac_id.choices = utils.get_model_choices(models.Mac, allow_none=True, attr='address')
         self.tags.choices = utils.get_model_choices(models.Tag, allow_none=True, attr='name')
+
+
+class HostInterfaceForm(HostForm, InterfaceForm):
+    pass
diff --git a/app/network/views.py b/app/network/views.py
index fdd817e..3ffb830 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -14,10 +14,10 @@ import sqlalchemy as sa
 from flask import (Blueprint, render_template, jsonify, session,
                    redirect, url_for, request, flash, current_app)
 from flask_login import login_required
-from .forms import HostForm, NetworkForm
+from .forms import HostForm, InterfaceForm, HostInterfaceForm, NetworkForm
 from ..extensions import db
 from ..decorators import login_groups_accepted
-from .. import models
+from .. import models, utils
 
 bp = Blueprint('network', __name__)
 
@@ -37,9 +37,12 @@ def create_host():
         network_id = session['network_id']
     except KeyError:
         # No need to pass request.form when no extra keywords are given
-        form = HostForm()
+        form = HostInterfaceForm()
     else:
-        form = HostForm(request.form, network_id=network_id)
+        form = HostInterfaceForm(request.form, network_id=network_id)
+    # Remove the host_id field inherited from the InterfaceForm
+    # It's not used in this form
+    del form.host_id
     if form.validate_on_submit():
         network_id = form.network_id.data
         host = models.Host(name=form.name.data,
@@ -74,6 +77,107 @@ def create_host():
     return render_template('network/create_host.html', form=form)
 
 
+@bp.route('/hosts/view/<name>')
+@login_required
+def view_host(name):
+    host = models.Host.query.filter_by(name=name).first_or_404()
+    return render_template('network/view_host.html', host=host)
+
+
+@bp.route('/hosts/edit/<name>', methods=('GET', 'POST'))
+@login_groups_accepted('admin', 'create')
+def edit_host(name):
+    host = models.Host.query.filter_by(name=name).first_or_404()
+    form = HostForm(request.form, obj=host)
+    if form.validate_on_submit():
+        host.name = form.name.data
+        host.type = form.type.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:
+            db.session.commit()
+        except sa.exc.IntegrityError as e:
+            db.session.rollback()
+            current_app.logger.warning(f'{e}')
+            flash(f'{e}', 'error')
+        else:
+            flash(f'Host {host} updated!', 'success')
+        return redirect(url_for('network.view_host', name=host.name))
+    return render_template('network/edit_host.html', form=form)
+
+
+@bp.route('/interfaces/create/<hostname>', methods=('GET', 'POST'))
+@login_groups_accepted('admin', 'create')
+def create_interface(hostname):
+    host = models.Host.query.filter_by(name=hostname).first_or_404()
+    form = InterfaceForm(request.form, host_id=host.id, interface_name=host.name)
+    if form.validate_on_submit():
+        # 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
+        all_tags = models.Tag.query.all()
+        tags = [tag for tag in all_tags if str(tag.id) in form.tags.data]
+        interface = models.Interface(host_id=host.id,
+                                     name=form.interface_name.data,
+                                     ip=form.ip.data,
+                                     network_id=form.network_id.data,
+                                     mac_id=form.mac_id.data,
+                                     tags=tags)
+        current_app.logger.debug(f'Trying to create: {interface!r}')
+        db.session.add(interface)
+        try:
+            db.session.commit()
+        except sa.exc.IntegrityError as e:
+            db.session.rollback()
+            current_app.logger.warning(f'{e}')
+            flash(f'{e}', 'error')
+        else:
+            flash(f'Host {interface} created!', 'success')
+        return redirect(url_for('network.create_interface', hostname=hostname))
+    return render_template('network/create_interface.html', form=form, hostname=hostname)
+
+
+@bp.route('/interfaces/edit/<name>', methods=('GET', 'POST'))
+@login_groups_accepted('admin', 'create')
+def edit_interface(name):
+    interface = models.Interface.query.filter_by(name=name).first_or_404()
+    form = InterfaceForm(request.form, obj=interface, interface_name=interface.name)
+    ips = [interface.ip]
+    ips.extend([str(address) for address in interface.network.available_ips()])
+    form.ip.choices = utils.get_choices(ips)
+    if form.validate_on_submit():
+        interface.name = form.interface_name.data
+        interface.ip = form.ip.data
+        interface.network_id = form.network_id.data
+        interface.mac_id = form.mac_id.data
+        all_tags = models.Tag.query.all()
+        tags = [tag for tag in all_tags if str(tag.id) in form.tags.data]
+        interface.tags = tags
+        current_app.logger.debug(f'Trying to update: {interface!r}')
+        try:
+            db.session.commit()
+        except sa.exc.IntegrityError as e:
+            db.session.rollback()
+            current_app.logger.warning(f'{e}')
+            flash(f'{e}', 'error')
+        else:
+            flash(f'Interface {interface} updated!', 'success')
+        return redirect(url_for('network.view_host', name=interface.host.name))
+    return render_template('network/edit_interface.html', form=form, hostname=interface.host.name)
+
+
+@bp.route('/interfaces/delete', methods=['POST'])
+@login_required
+def delete_interface():
+    interface = models.Interface.query.get_or_404(request.form['interface_id'])
+    hostname = interface.host.name
+    db.session.delete(interface)
+    db.session.commit()
+    flash(f'Interface {interface.name} has been deleted', 'success')
+    return redirect(url_for('network.view_host', name=hostname))
+
+
 @bp.route('/_retrieve_hosts')
 @login_required
 def retrieve_hosts():
diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js
index 1331099..c9bf938 100644
--- a/app/static/js/hosts.js
+++ b/app/static/js/hosts.js
@@ -16,9 +16,12 @@ $(document).ready(function() {
     );
   }
 
-  // Populate IP select field on first page load
-  // Populate vlan_id and prefix select field on first page load
-  if( $("#network_id").length ) {
+  // Populate IP select field on first page load for:
+  // - register new host
+  // - add interface
+  // Do NOT replace the IPs on edit interface page load!
+  // (we have to keep the existing IP)
+  if( $("#hostForm").length || $("#interfaceForm").length ) {
     update_available_ips();
   }
 
@@ -53,7 +56,22 @@ $(document).ready(function() {
           callback(json);
         });
     },
-    "paging": false
+    "pagingType": "full_numbers",
+    "pageLength": 20,
+    "lengthMenu": [[20, 50, 100, -1], [20, 50, 100, "All"]],
+    "columnDefs": [
+      {
+        "targets": [0],
+        "render": function(data, type, row) {
+          // render funtion to create link to Item view page
+          if ( data === null ) {
+            return data;
+          }
+          var url = $SCRIPT_ROOT + "/network/hosts/view/" + data;
+          return '<a href="'+ url + '">' + data + '</a>';
+        }
+      }
+    ]
   });
 
 });
diff --git a/app/templates/base-fluid.html b/app/templates/base-fluid.html
index 0835929..3aff063 100644
--- a/app/templates/base-fluid.html
+++ b/app/templates/base-fluid.html
@@ -17,7 +17,7 @@
         <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/inventory/qrcodes")) }}"
           href="{{ url_for('inventory.qrcodes', kind='Action') }}">QR Codes</a>
       {% elif path.startswith("/network") %}
-        <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/hosts")) }}"
+        <a class="list-group-item list-group-item-action {{ is_active(path.startswith(("/network/hosts", "/network/interfaces"))) }}"
           href="{{ url_for('network.list_hosts') }}">Hosts</a>
         <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/networks")) }}"
           href="{{ url_for('network.list_networks') }}">Networks</a>
diff --git a/app/templates/network/create_interface.html b/app/templates/network/create_interface.html
new file mode 100644
index 0000000..5fadfb9
--- /dev/null
+++ b/app/templates/network/create_interface.html
@@ -0,0 +1,34 @@
+{% extends "network/hosts.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Register Interface - CSEntry{% endblock %}
+
+{% block hosts_nav %}
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.view_host', name=hostname) }}">View host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.edit_host', name=hostname) }}">Edit host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.create_interface', hostname=hostname) }}">Add interface</a>
+    </li>
+{% endblock %}
+
+
+{% block hosts_main %}
+  <form id="interfaceForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.host_id, disabled=True) }}
+    {{ render_field(form.interface_name, class_="text-lowercase") }}
+    {{ render_field(form.network_id) }}
+    {{ render_field(form.ip) }}
+    {{ render_field(form.mac_id) }}
+    {{ render_field(form.tags) }}
+    <div class="form-group row">
+      <div class="col-sm-10">
+        <button type="submit" class="btn btn-primary">Submit</button>
+      </div>
+    </div>
+  </form>
+{%- endblock %}
diff --git a/app/templates/network/edit_host.html b/app/templates/network/edit_host.html
new file mode 100644
index 0000000..fedffe3
--- /dev/null
+++ b/app/templates/network/edit_host.html
@@ -0,0 +1,31 @@
+{% extends "network/hosts.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Edit Host - CSEntry{% endblock %}
+
+{% block hosts_nav %}
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.view_host', name=form.name.data) }}">View host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.edit_host', name=form.name.data) }}">Edit host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.create_interface', hostname=form.name.data) }}">Add interface</a>
+    </li>
+{% endblock %}
+
+{% block hosts_main %}
+  <form id="editHostForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.name, class_="text-lowercase") }}
+    {{ render_field(form.type) }}
+    {{ render_field(form.description) }}
+    {{ render_field(form.item_id, disabled=True) }}
+    <div class="form-group row">
+      <div class="col-sm-10">
+        <button type="submit" class="btn btn-primary">Submit</button>
+      </div>
+    </div>
+  </form>
+{%- endblock %}
diff --git a/app/templates/network/edit_interface.html b/app/templates/network/edit_interface.html
new file mode 100644
index 0000000..a7cfe3e
--- /dev/null
+++ b/app/templates/network/edit_interface.html
@@ -0,0 +1,37 @@
+{% extends "network/hosts.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Edit Interface - CSEntry{% endblock %}
+
+{% block hosts_nav %}
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.view_host', name=hostname) }}">View host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.edit_host', name=hostname) }}">Edit host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.create_interface', hostname=hostname) }}">Add interface</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.edit_interface', name=hostname) }}">Edit interface</a>
+    </li>
+{% endblock %}
+
+
+{% block hosts_main %}
+  <form id="editInterfaceForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.host_id, disabled=True) }}
+    {{ render_field(form.interface_name, class_="text-lowercase") }}
+    {{ render_field(form.network_id) }}
+    {{ render_field(form.ip) }}
+    {{ render_field(form.mac_id) }}
+    {{ render_field(form.tags) }}
+    <div class="form-group row">
+      <div class="col-sm-10">
+        <button type="submit" class="btn btn-primary">Submit</button>
+      </div>
+    </div>
+  </form>
+{%- endblock %}
diff --git a/app/templates/network/view_host.html b/app/templates/network/view_host.html
new file mode 100644
index 0000000..38efba3
--- /dev/null
+++ b/app/templates/network/view_host.html
@@ -0,0 +1,68 @@
+{% extends "network/hosts.html" %}
+{% from "_helpers.html" import link_to_item %}
+
+{% block title %}View Host - CSEntry{% endblock %}
+
+{% block hosts_nav %}
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.view_host', name=host.name) }}">View host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.edit_host', name=host.name) }}">Edit host</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.create_interface', hostname=host.name) }}">Add interface</a>
+    </li>
+{% endblock %}
+
+{% block hosts_main %}
+  <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">Item</dt>
+    <dd class="col-sm-9">{{ link_to_item(host.item) }}</dd>
+    {% endif %}
+    <dt class="col-sm-3">Description</dt>
+    <dd class="col-sm-9">{{ host.description }}</dd>
+  </dl>
+  <h3>Interfaces</h3>
+  <table id="interfaces_table" class="table table-hover table-sm">
+    <thead>
+      <tr>
+        <th width="5%"></th>
+        <th>Name</th>
+        <th>Cnames</th>
+        <th>IP</th>
+        <th>MAC</th>
+        <th>Network</th>
+        <th>Tags</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for interface in host.interfaces %}
+      <tr>
+        <td class="btn-group mr-2" role="group" aria-label="edit delete interface">
+          <a class="btn btn-light" role="button" href="{{ url_for('network.edit_interface', name=interface.name) }}" title="Edit interface">
+              <span class="oi oi-pencil" aria-hidden="true"></span>
+          </a>
+          <form method="POST" action="/network/interfaces/delete">
+            <input id="interface_id" name="interface_id" type="hidden" value="{{ interface.id }}">
+            <button type="submit" class="btn btn-light">
+              <span class="oi oi-trash" title="Delete interface" aria-hidden="true"></span>
+            </button>
+          </form>
+        </td>
+        <td>{{ interface.name }}</td>
+        <td>{{ interface.cnames | join(' ') }}</td>
+        <td>{{ interface.ip }}</td>
+        <td>{{ interface.mac }}</td>
+        <td>{{ interface.network }}</td>
+        <td>{{ interface.tags | join(' ') }}</td>
+      </tr>
+      {% endfor %}
+    </tbody>
+  </table>
+{%- endblock %}
-- 
GitLab