From a00090e0c6e64cdae3bc7ee58d868f260c96e692 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Wed, 11 Jul 2018 16:14:39 +0200
Subject: [PATCH] Add Ansible groups views

JIRA INFRA-412
---
 app/network/forms.py                    | 23 +++++++
 app/network/views.py                    | 85 +++++++++++++++++++++++++
 app/static/js/groups.js                 | 43 +++++++++++++
 app/templates/_helpers.html             | 16 +++++
 app/templates/base-fluid.html           |  2 +
 app/templates/network/create_group.html | 18 ++++++
 app/templates/network/create_host.html  |  1 +
 app/templates/network/edit_group.html   | 27 ++++++++
 app/templates/network/edit_host.html    |  1 +
 app/templates/network/groups.html       | 35 ++++++++++
 app/templates/network/view_group.html   | 34 ++++++++++
 app/templates/network/view_host.html    |  5 ++
 app/utils.py                            |  5 +-
 13 files changed, 294 insertions(+), 1 deletion(-)
 create mode 100644 app/static/js/groups.js
 create mode 100644 app/templates/network/create_group.html
 create mode 100644 app/templates/network/edit_group.html
 create mode 100644 app/templates/network/groups.html
 create mode 100644 app/templates/network/view_group.html

diff --git a/app/network/forms.py b/app/network/forms.py
index be0d235..f349683 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -144,10 +144,16 @@ class HostForm(CSEntryForm):
         "Ansible vars",
         description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html",
     )
+    ansible_groups = SelectMultipleField(
+        "Ansible groups", 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.ansible_groups.choices = utils.get_model_choices(
+            models.AnsibleGroup, attr="name"
+        )
 
 
 class InterfaceForm(CSEntryForm):
@@ -219,3 +225,20 @@ class CreateVMForm(CSEntryForm):
         super().__init__(*args, **kwargs)
         self.cores.choices = utils.get_choices(current_app.config["VM_CORES_CHOICES"])
         self.memory.choices = utils.get_choices(current_app.config["VM_MEMORY_CHOICES"])
+
+
+class AnsibleGroupForm(CSEntryForm):
+    name = StringField(
+        "name",
+        validators=[validators.InputRequired(), Unique(models.AnsibleGroup)],
+        filters=[utils.lowercase_field],
+    )
+    vars = YAMLField(
+        "Ansible vars",
+        description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html",
+    )
+    hosts = SelectMultipleField("Hosts", coerce=utils.coerce_to_str_or_none)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.hosts.choices = utils.get_model_choices(models.Host, attr="name")
diff --git a/app/network/views.py b/app/network/views.py
index 5487392..4104bac 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -31,6 +31,7 @@ from .forms import (
     NetworkScopeForm,
     DomainForm,
     CreateVMForm,
+    AnsibleGroupForm,
 )
 from ..extensions import db
 from ..decorators import login_groups_accepted
@@ -63,11 +64,15 @@ def create_host():
     del form.host_id
     if form.validate_on_submit():
         network_id = form.network_id.data
+        ansible_groups = [
+            models.AnsibleQuery.query.get(id_) for id_ in form.ansible_groups.data
+        ]
         host = models.Host(
             name=form.name.data,
             device_type_id=form.device_type_id.data,
             description=form.description.data or None,
             ansible_vars=form.ansible_vars.data or None,
+            ansible_groups=ansible_groups,
         )
         # The total number of tags will always be quite small
         # It's more efficient to retrieve all of them in one query
@@ -135,11 +140,20 @@ def view_host(name):
 def edit_host(name):
     host = models.Host.query.filter_by(name=name).first_or_404()
     form = HostForm(request.form, obj=host)
+    # Passing ansible_groups as kwarg to the HostForm doesn't work because
+    # obj takes precedence (but host.ansible_groups contain AnsibleGroup instances and not id)
+    # We need to update the default values. Calling process is required.
+    # See https://stackoverflow.com/questions/5519729/wtforms-how-to-select-options-in-selectmultiplefield
+    form.ansible_groups.default = [group.id for group in host.ansible_groups]
+    form.ansible_groups.process(request.form)
     if form.validate_on_submit():
         host.name = form.name.data
         host.device_type_id = form.device_type_id.data
         host.description = form.description.data or None
         host.ansible_vars = form.ansible_vars.data or None
+        host.ansible_groups = [
+            models.AnsibleGroup.query.get(id_) for id_ in form.ansible_groups.data
+        ]
         current_app.logger.debug(f"Trying to update: {host!r}")
         try:
             db.session.commit()
@@ -281,6 +295,70 @@ def delete_interface():
     return redirect(url_for("network.view_host", name=hostname))
 
 
+@bp.route("/groups")
+@login_required
+def list_ansible_groups():
+    return render_template("network/groups.html")
+
+
+@bp.route("/groups/view/<name>", methods=("GET", "POST"))
+@login_required
+def view_ansible_group(name):
+    group = models.AnsibleGroup.query.filter_by(name=name).first_or_404()
+    return render_template("network/view_group.html", group=group)
+
+
+@bp.route("/groups/edit/<name>", methods=("GET", "POST"))
+@login_groups_accepted("admin")
+def edit_ansible_group(name):
+    group = models.AnsibleGroup.query.filter_by(name=name).first_or_404()
+    form = AnsibleGroupForm(request.form, obj=group)
+    # Passing hosts as kwarg to the AnsibleGroupForm doesn't work because
+    # obj takes precedence (but group.hosts contain Host instances and not id)
+    # We need to update the default values. Calling process is required.
+    # See https://stackoverflow.com/questions/5519729/wtforms-how-to-select-options-in-selectmultiplefield
+    form.hosts.default = [host.id for host in group.hosts]
+    form.hosts.process(request.form)
+    if form.validate_on_submit():
+        group.name = form.name.data
+        group.vars = form.vars.data or None
+        group.hosts = [models.Host.query.get(id_) for id_ in form.hosts.data]
+        current_app.logger.debug(f"Trying to update: {group!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"Group {group} updated!", "success")
+        return redirect(url_for("network.view_ansible_group", name=group.name))
+    return render_template("network/edit_group.html", form=form)
+
+
+@bp.route("/groups/create", methods=("GET", "POST"))
+@login_groups_accepted("admin")
+def create_ansible_group():
+    form = AnsibleGroupForm()
+    if form.validate_on_submit():
+        hosts = [models.Host.query.get(id_) for id_ in form.hosts.data]
+        group = models.AnsibleGroup(
+            name=form.name.data, vars=form.vars.data or None, hosts=hosts
+        )
+        current_app.logger.debug(f"Trying to create: {group!r}")
+        db.session.add(group)
+        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"Group {group} created!", "success")
+        return redirect(url_for("network.view_ansible_group", name=group.name))
+    return render_template("network/create_group.html", form=form)
+
+
 @bp.route("/domains")
 @login_required
 def list_domains():
@@ -528,6 +606,13 @@ def retrieve_domains():
     return jsonify(data=data)
 
 
+@bp.route("/_retrieve_groups")
+@login_required
+def retrieve_groups():
+    data = [group.to_dict() for group in models.AnsibleGroup.query.all()]
+    return jsonify(data=data)
+
+
 @bp.route("/_generate_random_mac")
 @login_required
 def generate_random_mac():
diff --git a/app/static/js/groups.js b/app/static/js/groups.js
new file mode 100644
index 0000000..fc05e51
--- /dev/null
+++ b/app/static/js/groups.js
@@ -0,0 +1,43 @@
+$(document).ready(function() {
+
+  if( $("#groupForm").length || $("#editGroupForm").length ) {
+    var groupVarsEditor = CodeMirror.fromTextArea(vars, {
+        lineNumbers: true,
+        mode: "yaml"
+    });
+    groupVarsEditor.setSize(null, 120);
+  }
+
+  var groups_table =  $("#groups_table").DataTable({
+    "ajax": function(data, callback, settings) {
+      $.getJSON(
+        $SCRIPT_ROOT + "/network/_retrieve_groups",
+        function(json) {
+          callback(json);
+        });
+    },
+    "paging": false,
+    "columns": [
+      { data: 'name',
+        render: function(data, type, row) {
+          // render funtion to create link to group view page
+          if ( data === null ) {
+            return data;
+          }
+          var url = $SCRIPT_ROOT + "/network/groups/view/" + data;
+          return '<a href="' + url + '">' + data + '</a>';
+        }
+      },
+      { data: 'vars',
+        render: function(data, type, row) {
+          if ( data === "null" ) {
+            return "";
+          }
+          return '<pre>' + data + '</pre>';
+        }
+      },
+      { data: 'hosts' }
+    ]
+  });
+
+});
diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html
index c6f049a..987584c 100644
--- a/app/templates/_helpers.html
+++ b/app/templates/_helpers.html
@@ -6,6 +6,12 @@
   <a href="{{ url_for('network.view_host', name=name) }}">{{ name }}</a>
 {%- endmacro %}
 
+{% macro link_to_hosts(hosts) -%}
+  {% for host in hosts %}
+    {{ link_to_host(host.name) }}
+  {% endfor %}
+{%- endmacro %}
+
 {% macro link_to_item(ics_id) -%}
   {% if ics_id %}
     <a href="{{ url_for('inventory.view_item', ics_id=ics_id) }}">{{ ics_id }}</a>
@@ -34,6 +40,16 @@
   {% endfor %}
 {%- endmacro %}
 
+{% macro link_to_ansible_group(group) -%}
+  <a href="{{ url_for('network.view_ansible_group', name=group.name) }}">{{ group.name }}</a>
+{%- endmacro %}
+
+{% macro link_to_ansible_groups(groups) -%}
+  {% for group in groups %}
+    {{ link_to_ansible_group(group) }}
+  {% endfor %}
+{%- endmacro %}
+
 {% macro render_field(field) -%}
   {% set field_class = kwargs.pop('class_', '') + ' form-control' %}
   {% set label_size = kwargs.pop('label_size', '2') %}
diff --git a/app/templates/base-fluid.html b/app/templates/base-fluid.html
index 44b29d3..36d4295 100644
--- a/app/templates/base-fluid.html
+++ b/app/templates/base-fluid.html
@@ -25,6 +25,8 @@
           href="{{ url_for('network.list_scopes') }}">Network Scopes</a>
         <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/domains")) }}"
           href="{{ url_for('network.list_domains') }}">Domains</a>
+        <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/groups")) }}"
+          href="{{ url_for('network.list_ansible_groups') }}">Ansible groups</a>
         {% elif path.startswith("/task") %}
         <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/task/tasks")) }}"
           href="{{ url_for('task.list_tasks') }}">Tasks</a>
diff --git a/app/templates/network/create_group.html b/app/templates/network/create_group.html
new file mode 100644
index 0000000..41e6f64
--- /dev/null
+++ b/app/templates/network/create_group.html
@@ -0,0 +1,18 @@
+{% extends "network/groups.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Register Ansible group - CSEntry{% endblock %}
+
+{% block groups_main %}
+  <form id="groupForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.name, class_="text-lowercase") }}
+    {{ render_field(form.vars) }}
+    {{ render_field(form.hosts, class_="selectpicker") }}
+    <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/create_host.html b/app/templates/network/create_host.html
index f811580..85cb94c 100644
--- a/app/templates/network/create_host.html
+++ b/app/templates/network/create_host.html
@@ -17,6 +17,7 @@
     {{ render_field(form.cnames_string) }}
     {{ render_field(form.tags, class_="selectpicker") }}
     {{ render_field(form.ansible_vars) }}
+    {{ render_field(form.ansible_groups, class_="selectpicker") }}
     <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/edit_group.html b/app/templates/network/edit_group.html
new file mode 100644
index 0000000..155d1c0
--- /dev/null
+++ b/app/templates/network/edit_group.html
@@ -0,0 +1,27 @@
+{% extends "network/groups.html" %}
+{% from "_helpers.html" import render_field %}
+
+{% block title %}Edit Ansible group - CSEntry{% endblock %}
+
+{% block groups_nav %}
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.view_ansible_group', name=form.name.data) }}">View group</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.edit_ansible_group', name=form.name.data) }}">Edit group</a>
+    </li>
+{% endblock %}
+
+{% block groups_main %}
+  <form id="editGroupForm" method="POST">
+    {{ form.hidden_tag() }}
+    {{ render_field(form.name, class_="text-lowercase") }}
+    {{ render_field(form.vars) }}
+    {{ render_field(form.hosts, class_="selectpicker") }}
+    <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
index eadd244..97f6ecc 100644
--- a/app/templates/network/edit_host.html
+++ b/app/templates/network/edit_host.html
@@ -22,6 +22,7 @@
     {{ render_field(form.device_type_id) }}
     {{ render_field(form.description) }}
     {{ render_field(form.ansible_vars) }}
+    {{ render_field(form.ansible_groups, class_="selectpicker") }}
     <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/groups.html b/app/templates/network/groups.html
new file mode 100644
index 0000000..4792ded
--- /dev/null
+++ b/app/templates/network/groups.html
@@ -0,0 +1,35 @@
+{% extends "base-fluid.html" %}
+{% from "_helpers.html" import is_active %}
+
+{% block title %}Ansible Groups - CSEntry{% endblock %}
+
+{% block main %}
+  {% set path = request.path %}
+  <ul class="nav nav-tabs">
+    <li class="nav-item">
+      <a class="nav-link {{ is_active(path.endswith("/network/groups")) }}" href="{{ url_for('network.list_ansible_groups') }}">List groups</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link {{ is_active(path.startswith("/network/groups/create")) }}" href="{{ url_for('network.create_ansible_group') }}">Register new group</a>
+    </li>
+    {% block groups_nav %}{% endblock %}
+  </ul>
+
+  <br>
+
+  {% block groups_main %}
+  <table id="groups_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%">
+    <thead>
+      <tr>
+        <th>Name</th>
+        <th>Vars</th>
+        <th>Hosts</th>
+      </tr>
+    </thead>
+  </table>
+  {%- endblock %}
+{%- endblock %}
+
+{% block csentry_scripts %}
+  <script src="{{ url_for('static', filename='js/groups.js') }}"></script>
+{% endblock %}
diff --git a/app/templates/network/view_group.html b/app/templates/network/view_group.html
new file mode 100644
index 0000000..71c4cf7
--- /dev/null
+++ b/app/templates/network/view_group.html
@@ -0,0 +1,34 @@
+{% extends "network/groups.html" %}
+{% from "_helpers.html" import link_to_hosts %}
+
+{% block title %}View Ansible group - CSEntry{% endblock %}
+
+{% block groups_nav %}
+    <li class="nav-item">
+      <a class="nav-link active" href="{{ url_for('network.view_ansible_group', name=group.name) }}">View group</a>
+    </li>
+    <li class="nav-item">
+      <a class="nav-link" href="{{ url_for('network.edit_ansible_group', name=group.name) }}">Edit group</a>
+    </li>
+{% endblock %}
+
+{% block groups_main %}
+  <div class="row">
+    <div class="col-sm-9">
+      <dl class="row">
+        <dt class="col-sm-3">Name</dt>
+        <dd class="col-sm-9">{{ group.name }}</dd>
+        <dt class="col-sm-3">Variables</dt>
+        <dd class="col-sm-9"><pre>{{ group.vars | toyaml }}</pre></dd>
+        <dt class="col-sm-3">hosts</dt>
+        <dd class="col-sm-9">{{ link_to_hosts(group.hosts) }}</dd>
+        <dt class="col-sm-3">Created by</dt>
+        <dd class="col-sm-9">{{ group.user }}</dd>
+        <dt class="col-sm-3">Created at</dt>
+        <dd class="col-sm-9">{{ group.created_at | datetimeformat }}</dd>
+        <dt class="col-sm-3">Updated at</dt>
+        <dd class="col-sm-9">{{ group.updated_at | datetimeformat }}</dd>
+      </dl>
+    </div>
+  </div>
+{%- endblock %}
diff --git a/app/templates/network/view_host.html b/app/templates/network/view_host.html
index 8e3cd00..7eef111 100644
--- a/app/templates/network/view_host.html
+++ b/app/templates/network/view_host.html
@@ -1,5 +1,6 @@
 {% extends "network/hosts.html" %}
 {% from "_helpers.html" import link_to_items, link_to_stack_members,
+   link_to_ansible_groups,
    delete_button_with_confirmation, render_field, submit_button_with_confirmation %}
 
 {% block title %}View Host - CSEntry{% endblock %}
@@ -42,6 +43,10 @@
         <dt class="col-sm-3">Ansible vars</dt>
         <dd class="col-sm-9"><pre>{{ host.ansible_vars | toyaml }}</pre></dd>
         {% endif %}
+        {% if host.ansible_groups %}
+        <dt class="col-sm-3">Ansible groups</dt>
+        <dd class="col-sm-9">{{ link_to_ansible_groups(host.ansible_groups) }}</dd>
+        {% endif %}
       </dl>
     </div>
     {% if host.device_type.name.startswith('Virtual') and current_user.is_admin %}
diff --git a/app/utils.py b/app/utils.py
index 8191098..04a6d17 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -231,7 +231,10 @@ def pretty_yaml(value):
 
     Function used as a jinja2 filter
     """
-    return yaml.safe_dump(value, default_flow_style=False)
+    if value:
+        return yaml.safe_dump(value, default_flow_style=False)
+    else:
+        return ""
 
 
 def trigger_core_services_update():
-- 
GitLab