diff --git a/app/main/forms.py b/app/main/forms.py index e542e5b207f26b4dcc0561928f57efc28d864f5e..341bbb1231ee59bb5bf304ffedc345c7b995024a 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -11,7 +11,19 @@ This module defines the main forms. """ from flask_wtf import FlaskForm from wtforms import SelectField, StringField, validators -from .. import utils +from .. import utils, models + + +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 QRCodeForm(FlaskForm): @@ -23,3 +35,15 @@ class QRCodeForm(FlaskForm): self.kind.choices = utils.get_choices( ('Manufacturer', 'Model', 'Location') ) + + +class HostForm(FlaskForm): + network_id = SelectField('Network') + # The list of IPs is dynamically created on the browser side + # depending on the selected network + ip = NoValidateSelectField('IP', choices=[]) + name = StringField('Hostname') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.network_id.choices = [(str(network.id), network.prefix) for network in models.Network.query.all()] diff --git a/app/main/views.py b/app/main/views.py index bc1fb383c6a0224da9535d9d8a097bfd0419a14f..409b1c711f72190884a5b5b9292a389c56b2f4d0 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -11,13 +11,12 @@ This module implements the main blueprint. """ import sqlalchemy as sa from flask import (Blueprint, render_template, jsonify, - redirect, url_for, request, flash) + redirect, url_for, request, flash, current_app) from flask_login import login_required -from .forms import QRCodeForm +from .forms import QRCodeForm, HostForm from ..extensions import db -from ..models import Action, Manufacturer, Model, Location, Status, Item from ..decorators import login_groups_accepted -from .. import utils +from .. import utils, models bp = Blueprint('main', __name__) @@ -43,7 +42,7 @@ def handle_csentry_error(error): @bp.route('/_retrieve_items') @login_required def retrieve_items(): - items = Item.query.order_by(Item._created) + items = models.Item.query.order_by(models.Item._created) data = [[item.id, item.ics_id, utils.format_field(item._created), @@ -67,7 +66,7 @@ def index(): @bp.route('/view/<ics_id>') @login_required def view_item(ics_id): - item = Item.query.filter_by(ics_id=ics_id).first_or_404() + item = models.Item.query.filter_by(ics_id=ics_id).first_or_404() return render_template('view_item.html', item=item.to_dict(long=True)) @@ -75,7 +74,7 @@ def view_item(ics_id): @login_required def qrcodes(): codes = {} - for model in (Action, Manufacturer, Model, Location, Status): + for model in (models.Action, models.Manufacturer, models.Model, models.Location, models.Status): items = db.session.query(model).order_by(model.name) images = [{'name': item.name, 'data': utils.image_to_base64(item.image())} @@ -112,3 +111,47 @@ def retrieve_qrcodes_name(kind): items = db.session.query(model).order_by(model.name) data = [[item.name] for item in items] return jsonify(data=data) + + +@bp.route('/hosts', methods=('GET', 'POST')) +@login_groups_accepted('admin', 'create') +def hosts_index(): + # Try to get the network_id from the URL parameters + # to display the form with the same selected network + # when reloading the page (redirect after submit) + try: + network_id = request.args['network_id'] + except KeyError: + # No need to pass request.form when no extra keywords are given + form = HostForm() + else: + form = HostForm(request.form, network_id=network_id) + if form.validate_on_submit(): + host = models.Host(ip=form.ip.data, + network_id=form.network_id.data, + name=form.name.data) + current_app.logger.debug(f'Trying to create: {host!r}') + db.session.add(host) + try: + db.session.commit() + except sa.exc.IntegrityError as e: + db.session.rollback() + current_app.logger.warning(f'{e}') + flash(f'{e}', 'error') + return redirect(url_for('main.hosts_index', network_id=host.network_id)) + return render_template('hosts.html', form=form) + + +@bp.route('/_retrieve_hosts') +@login_required +def retrieve_hosts(): + data = [(host.name, host.ip, host.network.prefix) for host in models.Host.query.all()] + return jsonify(data=data) + + +@bp.route('/_retrieve_available_ips/<network_id>') +@login_required +def retrieve_available_ips(network_id): + network = models.Network.query.get(network_id) + data = [str(address) for address in network.available_ips()] + return jsonify(data=data) diff --git a/app/models.py b/app/models.py index 0710b0d6a5874e481a37a0618027ec97fc7e6014..c5b0969baeaf535a227a2f43d6e7d58a86158f03 100644 --- a/app/models.py +++ b/app/models.py @@ -377,7 +377,8 @@ class Host(db.Model): ip = db.Column(postgresql.INET, nullable=False, unique=True) name = db.Column(db.Text, unique=True) - mac = db.relationship('Mac', backref='host') + # This is a One To One relationship (set uselist to False) + mac = db.relationship('Mac', backref='host', uselist=False) def __init__(self, **kwargs): # Automatically convert network to an instance of Network if it was passed @@ -401,6 +402,9 @@ class Host(db.Model): def __str__(self): return str(self.ip) + def __repr__(self): + return f'Host(id={self.id}, network_id={self.network_id}, ip={self.ip}, name={self.name}, mac={self.mac})' + def to_dict(self, long=False): d = { 'id': self.id, diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js new file mode 100644 index 0000000000000000000000000000000000000000..38c7e994fa17adc41a2de729c78c42dbf2a63a40 --- /dev/null +++ b/app/static/js/hosts.js @@ -0,0 +1,38 @@ +$(document).ready(function() { + + function update_available_ips() { + // Retrieve available IPs for the selected network + // and update the IP select field + var network_id = $("#network_id").val(); + $.getJSON( + $SCRIPT_ROOT + "/_retrieve_available_ips/" + network_id, + function(json) { + var $ip = $("#ip"); + $ip.empty(); + $.map(json.data, function(option, index) { + $ip.append($("<option></option>").attr("value", option).text(option)); + }); + } + ); + } + + // Populate IP select field on first page load + update_available_ips(); + + // Update IP select field when changing network + $("#network_id").on('change', function() { + update_available_ips(); + }); + + var hosts_table = $("#hosts_table").DataTable({ + "ajax": function(data, callback, settings) { + $.getJSON( + $SCRIPT_ROOT + "/_retrieve_hosts", + function(json) { + callback(json); + }); + }, + "paging": false + }); + +}); diff --git a/app/templates/base.html b/app/templates/base.html index 19ed9bd0abeb2383f3ac0a3ca93677790885412a..c60b5a8608ee7b02dae617113d196d043ba40229 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -23,7 +23,8 @@ <div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="navbar-nav mr-auto"> {% set path = request.path %} - <a class="nav-item nav-link {{ is_active(path in ("/", "/index", "/index/")) }}" href="{{ url_for('main.index') }}">Index</a> + <a class="nav-item nav-link {{ is_active(path in ("/", "/index", "/index/")) }}" href="{{ url_for('main.index') }}">Items</a> + <a class="nav-item nav-link {{ is_active(path == "/hosts") }}" href="{{ url_for('main.hosts_index') }}">Hosts</a> <div class="dropdown {{ is_active(path.startswith("/qrcodes")) }}"> <a class="nav-link dropdown-toggle" href="#" id="qrcodesDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> QR Codes diff --git a/app/templates/hosts.html b/app/templates/hosts.html new file mode 100644 index 0000000000000000000000000000000000000000..14adf60ba46fa5dc0154180632f86726245098d4 --- /dev/null +++ b/app/templates/hosts.html @@ -0,0 +1,50 @@ +{%- extends "base.html" %} + +{% block title %}Hosts - CSEntry{% endblock %} + +{% block main %} + <h3>Host</h3> + + <form id="hostForm" method="POST"> + {{ form.hidden_tag() }} + <div class="form-group row"> + {{ form.network_id.label(class_="col-sm-2 col-form-label") }} + <div class="col-sm-10"> + {{ form.network_id(class_="form-control") }} + </div> + </div> + <div class="form-group row"> + {{ form.ip.label(class_="col-sm-2 col-form-label") }} + <div class="col-sm-10"> + {{ form.ip(class_="form-control") }} + </div> + </div> + <div class="form-group row"> + {{ form.name.label(class_="col-sm-2 col-form-label") }} + <div class="col-sm-10"> + {{ form.name(class_="form-control") }} + </div> + </div> + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-primary">Submit</button> + </div> + </div> + </form> + + <hr class="separator"> + + <table id="hosts_table" class="table table-bordered table-hover table-sm"> + <thead> + <tr> + <th>Hostname</th> + <th>IP</th> + <th>Network</th> + </tr> + </thead> + </table> +{%- endblock %} + +{% block csentry_scripts %} + <script src="{{ url_for('static', filename='js/hosts.js') }}"></script> +{% endblock %}