Verified Commit 4c7d24ce authored by Štěpán Henek's avatar Štěpán Henek 🌩

subordinates tab added + extra views added

* separate ajax form handling
* direct backend queries from ajax
parent 5bea7e27
......@@ -146,6 +146,40 @@ def reboot():
bottle.redirect(reverse("/"))
@login_required
def backend_api():
data = bottle.request.POST.decode()
def wrong_format():
raise bottle.HTTPError(400, "wrong incomming message format")
controller_id = data.get("controller_id")
if "action" not in data or "module" not in data or "kind" not in data:
wrong_format()
if data["kind"] != "request":
wrong_format()
msg_data = data.get("data")
if msg_data:
try:
msg_data = json.loads(msg_data)
except ValueError:
wrong_format()
resp = current_state.backend.perform(
data["module"], data["action"], msg_data, controller_id=controller_id
)
res = bottle.response.copy(cls=bottle.HTTPResponse)
res.content_type = 'application/json'
res.body = json.dumps(resp)
res.status = 200
raise res
@login_required
def leave_guide():
current_state.backend.perform("web", "update_guide", {
......@@ -189,6 +223,7 @@ def init_default_app(index, include_static=False):
app.install(CSRFPlugin())
app.route("/", name="index", callback=index)
app.route("/", method="POST", name="login", callback=index)
app.route("/backend-api", method="POST", name="backend-api", callback=backend_api)
app.route("/lang/<lang:re:\w{2}>", name="change_lang", callback=change_lang)
app.route("/logout", name="logout", callback=logout)
app.route("/reboot", name="reboot", callback=reboot)
......
This diff is collapsed.
# Foris - web administration interface for OpenWrt
# Copyright (C) 2019 CZ.NIC, z.s.p.o. <http://www.nic.cz>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import bottle
import typing
from foris import fapi
from foris.form import File, Hidden, Textbox
from foris.state import current_state
from foris.utils.translators import (
gettext_dummy as gettext,
gettext as _,
)
from .base import BaseConfigHandler
class SubordinatesConfigHandler(BaseConfigHandler):
userfriendly_title = gettext("Subordinates")
def get_form(self):
form = fapi.ForisForm("suboridinates", self.data)
section = form.add_section(
name="main_section",
title=_(self.userfriendly_title),
)
section.add_field(
File, name="token_file", label=_("Token file"), required=True)
def form_cb(data):
res = current_state.backend.perform(
"subordinates", "add_sub",
{"token": base64.b64encode(data["token_file"].file.read()).decode("utf-8")}
)
return "save_result", res
form.add_callback(form_cb)
return form
def get_controller_id_form(self, data=None):
controller_id_form = fapi.ForisForm("controller_id_form", data)
controller_section = controller_id_form.add_section("controller_section", title=None)
controller_section.add_field(
Hidden, name="controller_id", label="", required=True,
)
return controller_id_form
class SubordinatesEditForm(fapi.ForisAjaxForm):
template_name = "config/_subordinates_edit.html.j2"
def __init__(self, data, controller_id=None):
self.subordinate_controller_id = data["controller_id"]
super().__init__(data, controller_id)
self.title = _("Edit subordinate '%(controller_id)s'") % dict(
controller_id=data["controller_id"])
def convert_data_from_backend_to_form(self, backend_data):
subordinates_list = backend_data["subordinates"]
subordinates_map = {e["controller_id"]: e for e in subordinates_list}
sub_record = subordinates_map.get(self.subordinate_controller_id, None)
if not sub_record:
raise bottle.HTTPError(
404, f"Controller id {self.subordinate_controller_id} not found."
)
return sub_record["options"]
def convert_data_from_form_to_backend(self, data):
controller_id = data.pop("controller_id")
return {
"controller_id": controller_id,
"options": data
}
def make_form(self, data: typing.Optional[dict]):
form_data = self.convert_data_from_backend_to_form(
current_state.backend.perform("subordinates", "list")
)
if data:
form_data.update(data)
sub_form = fapi.ForisForm("update_sub", form_data)
sub_section = sub_form.add_section(
"subordinate_section", title="", description=_(
"You can edit you subordinate here. Subordinates are directly connected to this "
"device."
)
)
sub_section.add_field(
Textbox, name="custom_name", label=_("Custom Name"),
hint=_("Nicer name for your device '%(controller_id)s'.")
% dict(controller_id=data["controller_id"])
)
sub_section.add_field(
Hidden, name="controller_id", required=True, title="",
)
def form_cb(data):
msg_data = self.convert_data_from_form_to_backend(data)
res = current_state.backend.perform("subordinates", "update_sub", msg_data)
return "save_result", res
sub_form.add_callback(form_cb)
return sub_form
class SubsubordinatesEditForm(fapi.ForisAjaxForm):
template_name = "config/_subordinates_edit.html.j2"
def __init__(self, data, controller_id=None):
self.subsubordinate_controller_id = data["controller_id"]
super().__init__(data, controller_id)
self.title = _("Edit subsubordinate '%(controller_id)s'") % dict(
controller_id=data["controller_id"])
def convert_data_from_backend_to_form(self, backend_data):
subordinates_list = backend_data["subordinates"]
subsubordinates_map = {
e["controller_id"]: e
for record in subordinates_list
for e in record["subsubordinates"]
}
subsub_record = subsubordinates_map.get(self.subsubordinate_controller_id, None)
if not subsub_record:
raise bottle.HTTPError(
404, f"Controller id {self.subsubordinate_controller_id} not found."
)
return subsub_record["options"]
def convert_data_from_form_to_backend(self, data):
controller_id = data.pop("controller_id")
return {
"controller_id": controller_id,
"options": data
}
def make_form(self, data: typing.Optional[dict]):
form_data = self.convert_data_from_backend_to_form(
current_state.backend.perform("subordinates", "list")
)
if data:
form_data.update(data)
sub_form = fapi.ForisForm("update_subsub", form_data)
sub_section = sub_form.add_section(
"subsubordinate_section", title="", description=_(
"You can edit you subsubordinate here. Subsubordinates are "
"devices which are not directly connected to this device but "
"they are connected with a subordinated."
)
)
sub_section.add_field(
Hidden, name="controller_id", required=True, title="",
)
sub_section.add_field(
Textbox, name="custom_name", label=_("Custom Name"),
hint=_("Nicer name for your device with serial '%(controller_id)s'.")
% dict(controller_id=self.subsubordinate_controller_id)
)
def form_cb(data):
res = current_state.backend.perform(
"subordinates", "update_subsub",
{
"controller_id": data["controller_id"],
"options": {"custom_name": data["custom_name"]}
}
)
return "save_result", res
sub_form.add_callback(form_cb)
return sub_form
......@@ -14,6 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import typing
from foris import fapi
from foris import validators
from foris.form import Checkbox, Dropdown, PasswordWithHide, Radio, Textbox, HorizontalLine
......@@ -27,15 +29,28 @@ from .base import BaseConfigHandler
class WifiHandler(BaseConfigHandler):
userfriendly_title = gettext("Wi-Fi")
def get_form(self):
ajax_form = WifiEditForm(self.data)
return ajax_form.foris_form
class WifiEditForm(fapi.ForisAjaxForm):
template_name = "config/_wifi_edit.html.j2"
def __init__(self, data, controller_id=None):
super().__init__(data, controller_id)
self.title = _("Setting WiFi for '%s'") % controller_id
@staticmethod
def prefixed(index, name):
return "radio%d-%s" % (index, name)
def _backend_data_to_form_data(self, backend_data):
def convert_data_from_backend_to_form(self, backend_data: dict) -> dict:
form_data = {}
for device in backend_data["devices"]:
def prefixed(name):
return WifiHandler.prefixed(device["id"], name)
return WifiEditForm.prefixed(device["id"], name)
form_data[prefixed("device_enabled")] = device["enabled"]
form_data[prefixed("ssid")] = device["SSID"]
form_data[prefixed("ssid_hidden")] = device["hidden"]
......@@ -49,6 +64,82 @@ class WifiHandler(BaseConfigHandler):
return form_data
def convert_data_from_form_to_backend(
self, form_data: dict, device_ids: typing.List[str]
) -> dict:
res = []
for dev_id in device_ids:
def prefixed(name):
return WifiEditForm.prefixed(dev_id, name)
dev_rec = {"id": dev_id}
dev_rec["enabled"] = form_data[prefixed("device_enabled")]
if dev_rec["enabled"]:
dev_rec["SSID"] = form_data[prefixed("ssid")]
dev_rec["hidden"] = form_data[prefixed("ssid_hidden")]
dev_rec["hwmode"] = form_data[prefixed("hwmode")]
dev_rec["htmode"] = form_data[prefixed("htmode")]
dev_rec["channel"] = int(form_data[prefixed("channel")])
dev_rec["guest_wifi"] = {}
dev_rec["guest_wifi"]["enabled"] = form_data.get(prefixed("guest_enabled"), False)
dev_rec["password"] = form_data[prefixed("password")]
if dev_rec["guest_wifi"]["enabled"]:
dev_rec["guest_wifi"]["SSID"] = form_data[prefixed("guest_ssid")]
dev_rec["guest_wifi"]["password"] = form_data[prefixed("guest_password")]
res.append(dev_rec)
return {"devices": res}
def make_form(self, data: typing.Optional[dict]) -> fapi.ForisForm:
backend_data = current_state.backend.perform(
"wifi", "get_settings", controller_id=self.controller_id)
form_data = self.convert_data_from_backend_to_form(backend_data)
if data:
form_data.update(data)
wifi_form = fapi.ForisForm("wifi", form_data)
wifi_form.add_section(
name="wifi",
title=_("Wi-Fi"),
description=_(
"If you want to use your router as a Wi-Fi access point, enable Wi-Fi "
"here and fill in an SSID (the name of the access point) and a "
"corresponding password. You can then set up your mobile devices, "
"using the QR code available within the form."
)
)
# Add wifi section
wifi_section = wifi_form.add_section(
name="wifi_settings",
title=_("Wi-Fi settings"),
)
for idx, device in enumerate(backend_data["devices"]):
prefix = WifiEditForm.prefixed(device["id"], "")
device_form_data = {
k[len(prefix):]: v for k, v in form_data.items()
if k.startswith(prefix)
} # prefix removed
self._prepare_device_fields(
wifi_section, device, device_form_data, len(backend_data["devices"]) - 1 == idx
)
def form_cb(data):
update_data = self.convert_data_from_form_to_backend(
data, [e["id"] for e in backend_data["devices"]])
res = current_state.backend.perform(
"wifi", "update_settings", update_data, controller_id=self.controller_id)
return "save_result", res
wifi_form.add_callback(form_cb)
return wifi_form
def _prepare_device_fields(self, section, device, form_data, last=False):
HINTS = {
'password': _(
......@@ -58,7 +149,7 @@ class WifiHandler(BaseConfigHandler):
}
def prefixed(name):
return WifiHandler.prefixed(device["id"], name)
return WifiEditForm.prefixed(device["id"], name)
# get corresponding band
bands = [e for e in device["available_bands"] if e["hwmode"] == form_data["hwmode"]]
......@@ -179,73 +270,3 @@ class WifiHandler(BaseConfigHandler):
wifi_main.add_field(
HorizontalLine, name=prefixed("wifi-separator"), class_="wifi-separator"
).requires(prefixed("device_enabled"), True)
def _form_data_to_backend_data(self, form_data, device_ids):
res = []
for dev_id in device_ids:
def prefixed(name):
return WifiHandler.prefixed(dev_id, name)
dev_rec = {"id": dev_id}
dev_rec["enabled"] = form_data[prefixed("device_enabled")]
if dev_rec["enabled"]:
dev_rec["SSID"] = form_data[prefixed("ssid")]
dev_rec["hidden"] = form_data[prefixed("ssid_hidden")]
dev_rec["hwmode"] = form_data[prefixed("hwmode")]
dev_rec["htmode"] = form_data[prefixed("htmode")]
dev_rec["channel"] = int(form_data[prefixed("channel")])
dev_rec["guest_wifi"] = {}
dev_rec["guest_wifi"]["enabled"] = form_data.get(prefixed("guest_enabled"), False)
dev_rec["password"] = form_data[prefixed("password")]
if dev_rec["guest_wifi"]["enabled"]:
dev_rec["guest_wifi"]["SSID"] = form_data[prefixed("guest_ssid")]
dev_rec["guest_wifi"]["password"] = form_data[prefixed("guest_password")]
res.append(dev_rec)
return {"devices": res}
def get_form(self):
backend_data = current_state.backend.perform("wifi", "get_settings")
form_data = self._backend_data_to_form_data(backend_data)
if self.data:
form_data.update(self.data)
wifi_form = fapi.ForisForm("wifi", form_data)
wifi_form.add_section(
name="wifi",
title=_(self.userfriendly_title),
description=_(
"If you want to use your router as a Wi-Fi access point, enable Wi-Fi "
"here and fill in an SSID (the name of the access point) and a "
"corresponding password. You can then set up your mobile devices, "
"using the QR code available within the form."
)
)
# Add wifi section
wifi_section = wifi_form.add_section(
name="wifi_settings",
title=_("Wi-Fi settings"),
)
for idx, device in enumerate(backend_data["devices"]):
prefix = WifiHandler.prefixed(device["id"], "")
device_form_data = {
k[len(prefix):]: v for k, v in form_data.items()
if k.startswith(prefix)
} # prefix removed
self._prepare_device_fields(
wifi_section, device, device_form_data, len(backend_data["devices"]) - 1 == idx
)
def form_cb(data):
update_data = self._form_data_to_backend_data(
data, [e["id"] for e in backend_data["devices"]])
res = current_state.backend.perform("wifi", "update_settings", update_data)
return "save_result", res # store {"result": ...} to be used later...
wifi_form.add_callback(form_cb)
return wifi_form
......@@ -17,6 +17,7 @@
from collections import defaultdict, OrderedDict
import copy
import logging
import typing
from bottle import MultiDict
......@@ -27,6 +28,31 @@ from foris import validators as validators_module
logger = logging.getLogger(__name__)
class ForisAjaxForm(object):
title: typing.Optional[str] = None
template_name: typing.Optional[str] = None
url: typing.Optional[str] = None
controller_id: typing.Optional[str] = None
def make_form(self, data: typing.Optional[dict]):
raise NotImplementedError()
def convert_data_from_form_to_backend(self, *args, **kwargs):
raise NotImplementedError()
def convert_data_from_backend_to_form(self, *args, **kwargs):
raise NotImplementedError()
def __init__(self, data=None, controller_id=None):
self.controller_id = controller_id
self.foris_form = self.make_form(data)
# inject controller id
if controller_id: # controller_id used for queries
self.foris_form.add_section("hidden", title="").add_field(
Hidden, name="_controller_id", required=True, default=self.controller_id
)
class ForisFormElement(object):
def __init__(self, name):
self.name = name
......@@ -52,6 +78,7 @@ class ForisFormElement(object):
class ForisForm(ForisFormElement):
def __init__(self, name, data=None, validators=[]):
"""
......
......@@ -208,14 +208,14 @@ Foris.initPasswordHiding = function() {
Foris.afterAjaxUpdateFunctions = [];
Foris.afterAjaxUpdate = function(response, status, xhr) {
for (var i=0; i < Foris.afterAjaxUpdateFunctions.length; i++) {
Foris.afterAjaxUpdateFunctions[i](response, status, xhr);
for (let afterFunction of Foris.afterAjaxUpdateFunctions) {
afterFunction(response, status, xhr);
}
}
Foris.updateForm = function (form) {
var serialized = form.serializeArray();
serialized.push({name: 'update', value: '1'});
serialized.push({name: '_update', value: '1'});
var idSelector = form.attr("id") ? " #" + form.attr("id") : "";
form.load(form.attr("action") + idSelector, serialized, function (response, status, xhr) {
......@@ -231,10 +231,10 @@ Foris.updateForm = function (form) {
}
$(this).children(':first').unwrap();
Foris.initParsley();
Foris.initPasswordHiding();
Foris.afterAjaxUpdate();
Foris.initClicksQR();
Foris.initParsley(response, status, xhr);
Foris.initPasswordHiding(response, status, xhr);
Foris.afterAjaxUpdate(response, status, xhr);
Foris.initClicksQR(response, status, xhr);
$(document).trigger('formupdate', [form]);
});
form.find("input, select, button").attr("disabled", "disabled");
......@@ -735,6 +735,28 @@ Foris.clearNetworkWarnings = function(network_name, data) {
}
}
Foris.performBackendQuery = async (controller_id, module, action, data) => {
let csrf = $('meta[name=csrf]').prop("content");
let output = {
module: module,
kind: "request",
action: action,
};
if (controller_id) {
output.controller_id = controller_id;
}
if (data) {
output.data = JSON.stringify(data);
}
output.csrf_token = csrf;
return await $.ajax({
type: "POST",
url: Foris.backendPath,
dataType: "json",
data: output,
});
};
$(document).ready(function () {
Foris.initialize();
......
table
tbody
tr
td
padding: 0.2em 0.5em
thead
th
font-weight: bold
padding: 0.2em 0.5em
td, th
padding: 0.3em 0.8em
border-bottom: 1px solid #fff
tr:first-child td
padding-top: 0.5em
tbody tr
&:nth-child(2n) td
background: #f2f2f2
&:hover td
border-bottom: 1px solid #00a2e2
th
font-weight: bold
border-bottom: 1px solid #ddd
margin-bottom: 2px
tbody td
text-align: center
......@@ -34,6 +34,7 @@
{% if foris_info.websockets["wss_path"] %}
<meta name="foris-wss-path" content="{{ foris_info.websockets["wss_path"] }}">
{% endif %}
<meta name="csrf" content="{{ get_csrf_token() }}">
<script src="{{ static("js/contrib/jquery-3.3.1.min.js") }}"></script>
<script src="{{ static("js/contrib/parsley.min.js") }}"></script>
<script src="{{ static("js/contrib/vex.combined.min.js") }}"></script>
......
{% if message %}
<div {% if dom_id %}id="{{ dom_id }}"{% endif %} class="message {{ " ".join(message.classes) }}">
{{ message.text|safe }}
</div>
{% endif %}
<div id="subordinates-edit" style="display: none">
<h3>{{ ajax_form.title }}</h3>
{% include 'config/_message.html.j2' %}
<form id="sub-form" class="config-form" action="{{ ajax_form.url }}" method="post" autocomplete="off" novalidate>
<p class="config-description">{{ form.sections[0].description|safe }}</p>
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<input type="hidden" name="action" value="{{ form.name }}">
{% for field in form.active_fields %}
{% include '_field.html.j2' %}
{% endfor %}
<div id="{{ 'form-%s-buttons' % form.name }}" class="form-buttons">
<a href="#" class="button grayed">{% trans %}Close{% endtrans %}</a>
<button type="submit" name="apply" class="button">{% trans %}Apply{% endtrans %}</button>
</div>
</form>
</div>
{% macro make_simple_edit_form(controller_id, form_name) -%}
<form action="{{ url("config_ajax_form", page_name="subordinates", form_name=form_name) }}" method="post" class="subordinate-buttons">
<input type="hidden" name="controller_id" value="{{ controller_id }}">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<button name="action" value="edit" type="submit"><i class="fas fa-edit"></i></button>
</form>
{% endmacro -%}
{% macro sub_buttons(controller_id, enabled, form_class) -%}
<form action="{{ url("config_ajax", page_name="subordinates") }}" method="post" class="subordinate-buttons {{ form_class }}">
<input type="hidden" name="controller_id" value="{{ controller_id }}">
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
{% if enabled %}
<button name="action" value="disable" type="submit"><i class="fas fa-pause"></i></button>
{% else %}
<button name="action" value="enable" type="submit"><i class="fas fa-play"></i></button>
{% endif %}
<button name="action" value="delete" type="submit"><i class="fas fa-trash-alt"></i> </button>
<button name="action" value="edit" type="submit"><i class="fas fa-edit"></i></button>
</form>
{%- endmacro %}
{% macro convert_state(enabled, sup_enabled) -%}
{% if enabled and sup_enabled %}
<i class="fas fa-question-circle state"></i>
{% else %}
<i class="fas fa-pause-circle state"></i>
{% endif %}
{%- endmacro %}
{%- macro should_be_connected_class(enabled, sup_enabled) %}
{% if enabled and sup_enabled %} sub-connection{% endif %}
{%- endmacro %}
{% if subordinates %}
<table id="subordinates-table">
{% if view == "wifi" %}
<thead><th>{% trans %}ID{% endtrans %}</th><th>{% trans %}State{% endtrans %}</th><th>{% trans %}Devices{% endtrans %}</th><th>{% trans %}Channels{% endtrans %}</th><th></th></thead>
<tbody>
{% for sub in subordinates %}
<tr id="sub-{{ sub.controller_id }}" class="sub-line {{ should_be_connected_class(sub.enabled, True) }}">
<td>{{ sub.options.custom_name or sub.controller_id }}</td>
<td>
{{ convert_state(sub.enabled, True) }}
</td>
<td class="wifi-count"><i class="fas fa-spinner rotate"></i></td>
<td class="wifi-channels"><i class="fas fa-spinner rotate"></i></td>
<td>
{{ make_simple_edit_form(sub.controller_id, "wifi") }}
</td>
</tr>
{% for subsub in sub.subsubordinates %}
<tr id="sub-{{ subsub.controller_id }}" class="sub-line {{ should_be_connected_class(subsub.enabled, sub.enabled) }}" >
<td><i class="fas fa-angle-right"></i>
{{ subsub.options.custom_name or subsub.controller_id }}
</td>
<td>
{{ convert_state(subsub.enabled, sub.enabled) }}
</td>
<td class="wifi-count"><i class="fas fa-spinner rotate"></i></td>
<td class="wifi-channels"><i class="fas fa-spinner rotate"></i></td>
<td>
{{ make_simple_edit_form(subsub.controller_id, "wifi") }}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
{% else %}
<thead><th>{% trans %}ID{% endtrans %}</th><th>{% trans %}State{% endtrans %}</th><th></th></thead>
<tbody>
{% for sub in subordinates %}
<tr id="sub-{{ sub.controller_id }}" class="sub-line {{ should_be_connected_class(sub.enabled, True) }}">
<td>{{ sub.options.custom_name or sub.controller_id }}</td>
<td>
{{ convert_state(sub.enabled, True) }}
</td>
<td>{{ sub_buttons(sub.controller_id, sub.enabled, "sub-form") }}</td>
</tr>
{% for subsub in sub.subsubordinates %}
<tr id="sub-{{ subsub.controller_id }}" class="sub-line {{ should_be_connected_class(subsub.enabled, sub.enabled) }}">
<td><i class="fas fa-angle-right"></i>
{{ subsub.options.custom_name or subsub.controller_id }}
</td>
<td>
{{ convert_state(subsub.enabled, sub.enabled) }}
</td>
<td>{{ sub_buttons(subsub.controller_id, subsub.enabled, "subsub-form") }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
{% endif %}
</table>
{% else %}
<p id="subordinates-table">{% trans %}Currently there are no subordinates.{% endtrans %}</p>
{% endif %}
<div id="subordinates-edit" style="display: none">
<h3>{{ ajax_form.title }}</h3>
<form id="form-{{ form.name }}" class="config-form" action="{{ ajax_form.url }}" method="post" autocomplete="off" novalidate>