diff --git a/owrx/controllers/settings/__init__.py b/owrx/controllers/settings/__init__.py index c96645c..85bd2a9 100644 --- a/owrx/controllers/settings/__init__.py +++ b/owrx/controllers/settings/__init__.py @@ -1,6 +1,7 @@ from owrx.config import Config from owrx.controllers.admin import AuthorizationMixin from owrx.controllers.template import WebpageController +from owrx.form.error import FormError from abc import ABCMeta, abstractmethod from urllib.parse import parse_qs @@ -10,16 +11,16 @@ class Section(object): self.title = title self.inputs = inputs - def render_input(self, input, data): - return input.render(data) + def render_input(self, input, data, errors): + return input.render(data, errors) - def render_inputs(self, data): - return "".join([self.render_input(i, data) for i in self.inputs]) + def render_inputs(self, data, errors): + return "".join([self.render_input(i, data, errors) for i in self.inputs]) def classes(self): return ["col-12", "settings-section"] - def render(self, data): + def render(self, data, errors): return """

@@ -28,11 +29,20 @@ class Section(object): {inputs}

""".format( - classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data) + classes=" ".join(self.classes()), title=self.title, inputs=self.render_inputs(data, errors) ) def parse(self, data): - return {k: v for i in self.inputs for k, v in i.parse(data).items()} + parsed_data = {} + errors = [] + for i in self.inputs: + try: + parsed_data.update(i.parse(data)) + except FormError as e: + errors.append(e) + except Exception as e: + errors.append(FormError(i.id, "{}: {}".format(type(e).__name__, e))) + return parsed_data, errors class SettingsController(AuthorizationMixin, WebpageController): @@ -41,6 +51,10 @@ class SettingsController(AuthorizationMixin, WebpageController): class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=ABCMeta): + def __init__(self, handler, request, options): + super().__init__(handler, request, options) + self.errors = {} + @abstractmethod def getSections(self): pass @@ -52,8 +66,11 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def getData(self): return Config.get() + def getErrors(self): + return self.errors + def render_sections(self): - sections = "".join(section.render(self.getData()) for section in self.getSections()) + sections = "".join(section.render(self.getData(), self.getErrors()) for section in self.getSections()) buttons = self.render_buttons() return """
@@ -84,15 +101,34 @@ class SettingsFormController(AuthorizationMixin, WebpageController, metaclass=AB def parseFormData(self): data = parse_qs(self.get_body().decode("utf-8"), keep_blank_values=True) - return {k: v for i in self.getSections() for k, v in i.parse(data).items()} + result = {} + errors = [] + for section in self.getSections(): + section_data, section_errors = section.parse(data) + result.update(section_data) + errors += section_errors + return result, errors def getSuccessfulRedirect(self): return self.request.path + def _mergeErrors(self, errors): + result = {} + for e in errors: + if e.getKey() not in result: + result[e.getKey()] = [] + result[e.getKey()].append(e.getMessage()) + return result + def processFormData(self): - self.processData(self.parseFormData()) - self.store() - self.send_redirect(self.getSuccessfulRedirect()) + data, errors = self.parseFormData() + if errors: + self.errors = self._mergeErrors(errors) + self.indexAction() + else: + self.processData(data) + self.store() + self.send_redirect(self.getSuccessfulRedirect()) def processData(self, data): config = self.getData() diff --git a/owrx/controllers/settings/sdr.py b/owrx/controllers/settings/sdr.py index a655a6d..524f1b1 100644 --- a/owrx/controllers/settings/sdr.py +++ b/owrx/controllers/settings/sdr.py @@ -8,6 +8,7 @@ from owrx.controllers.settings import Section from urllib.parse import quote, unquote from owrx.sdr import SdrService from owrx.form import TextInput, DropdownInput, Option +from owrx.form.validator import RequiredValidator from owrx.property import PropertyLayer, PropertyStack from abc import ABCMeta, abstractmethod @@ -235,7 +236,7 @@ class SdrDeviceController(SdrFormControllerWithModal): if self.device is None: self.send_response("device not found", code=404) return - self.serve_template("settings/general.html", **self.template_variables()) + super().indexAction() def processFormData(self): if self.device is None: @@ -276,7 +277,7 @@ class NewSdrDeviceController(SettingsFormController): "New device settings", TextInput("name", "Device name"), DropdownInput("type", "Device type", [Option(name, name) for name in SdrDeviceDescription.getTypes()]), - TextInput("id", "Device ID"), + TextInput("id", "Device ID", validator=RequiredValidator()), ) ] @@ -331,7 +332,7 @@ class SdrProfileController(SdrFormControllerWithModal): if self.profile is None: self.send_response("profile not found", code=404) return - self.serve_template("settings/general.html", **self.template_variables()) + super().indexAction() def processFormData(self): if self.profile is None: @@ -377,7 +378,7 @@ class NewProfileController(SdrProfileController): return [ Section( "New profile settings", - TextInput("id", "Profile ID"), + TextInput("id", "Profile ID", validator=RequiredValidator()), ) ] + super().getSections() diff --git a/owrx/form/__init__.py b/owrx/form/__init__.py index f53cc00..4968c1e 100644 --- a/owrx/form/__init__.py +++ b/owrx/form/__init__.py @@ -1,16 +1,18 @@ -from abc import ABC, abstractmethod +from abc import ABC from owrx.modes import Modes from owrx.config import Config +from owrx.form.validator import Validator from owrx.form.converter import Converter, NullConverter, IntConverter, FloatConverter, EnumConverter from enum import Enum class Input(ABC): - def __init__(self, id, label, infotext=None, converter: Converter = None, disabled=False, removable=False): + def __init__(self, id, label, infotext=None, converter: Converter = None, validator: Validator = None, disabled=False, removable=False): self.id = id self.label = label self.infotext = infotext self.converter = self.defaultConverter() if converter is None else converter + self.validator = validator self.disabled = disabled self.removable = removable @@ -24,7 +26,6 @@ class Input(ABC): return NullConverter() def bootstrap_decorate(self, input): - infotext = "{text}".format(text=self.infotext) if self.infotext else "" return """
@@ -40,19 +41,22 @@ class Input(ABC): id=self.id, label=self.label, input=input, - infotext=infotext, + infotext="{text}".format(text=self.infotext) if self.infotext else "", removable="removable" if self.removable else "", removebutton='' if self.removable else "", ) - def input_classes(self): - return " ".join(["form-control", "form-control-sm"]) + def input_classes(self, error): + classes = ["form-control", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) - def input_properties(self, value): + def input_properties(self, value, error): props = { - "class": self.input_classes(), + "class": self.input_classes(error), "id": self.id, "name": self.id, "placeholder": self.label, @@ -62,26 +66,35 @@ class Input(ABC): props["disabled"] = "disabled" return props - def render_input_properties(self, value): - return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value).items()) + def render_input_properties(self, value, error): + return " ".join('{}="{}"'.format(prop, value) for prop, value in self.input_properties(value, error).items()) - def render_input(self, value): - return "".format(properties=self.render_input_properties(value)) + def render_errors(self, errors): + return "".join("""
{msg}
""".format(msg=e) for e in errors) - def render(self, config): + def render_input(self, value, errors): + return "{errors}".format( + properties=self.render_input_properties(value, errors), errors=self.render_errors(errors) + ) + + def render(self, config, errors): value = config[self.id] if self.id in config else None - return self.bootstrap_decorate(self.render_input(self.converter.convert_to_form(value))) + error = errors[self.id] if self.id in errors else [] + return self.bootstrap_decorate(self.render_input(self.converter.convert_to_form(value), error)) def parse(self, data): - return {self.id: self.converter.convert_from_form(data[self.id][0])} if self.id in data else {} + value = self.converter.convert_from_form(data[self.id][0]) + if self.validator is not None: + self.validator.validate(self.id, value) + return {self.id: value} if self.id in data else {} def getLabel(self): return self.label class TextInput(Input): - def input_properties(self, value): - props = super().input_properties(value) + def input_properties(self, value, errors): + props = super().input_properties(value, errors) props["type"] = "text" return props @@ -95,14 +108,14 @@ class NumberInput(Input): def defaultConverter(self): return IntConverter() - def input_properties(self, value): - props = super().input_properties(value) + def input_properties(self, value, errors): + props = super().input_properties(value, errors) props["type"] = "number" if self.step: props["step"] = self.step return props - def render_input(self, value): + def render_input(self, value, errors): if self.append: append = """
@@ -120,7 +133,7 @@ class NumberInput(Input): {append}
""".format( - input=super().render_input(value), + input=super().render_input(value, errors), append=append, ) @@ -135,7 +148,8 @@ class FloatInput(NumberInput): class LocationInput(Input): - def render_input(self, value): + def render_input(self, value, errors): + # TODO display errors return """
{inputs} @@ -145,11 +159,11 @@ class LocationInput(Input):
""".format( id=self.id, - inputs="".join(self.render_sub_input(value, id) for id in ["lat", "lon"]), + inputs="".join(self.render_sub_input(value, id, errors) for id in ["lat", "lon"]), key=Config.get()["google_maps_api_key"], ) - def render_sub_input(self, value, id): + def render_sub_input(self, value, id, errors): return """
{value} + {errors} """.format( - id=self.id, classes=self.input_classes(), value=value, disabled="disabled" if self.disabled else "" + id=self.id, + classes=self.input_classes(errors), + value=value, + disabled="disabled" if self.disabled else "", + errors=self.render_errors(errors), ) @@ -181,7 +200,7 @@ class CheckboxInput(Input): super().__init__(id, "", infotext=infotext, converter=converter) self.checkboxText = checkboxText - def render_input(self, value): + def render_input(self, value, errors): return """
@@ -189,17 +208,22 @@ class CheckboxInput(Input): + {errors}
""".format( id=self.id, - classes=self.input_classes(), + classes=self.input_classes(errors), checked="checked" if value else "", disabled="disabled" if self.disabled else "", checkboxText=self.checkboxText, + errors=self.render_errors(errors) ) - def input_classes(self): - return " ".join(["form-check", "form-control-sm"]) + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) def parse(self, data): if self.id in data: @@ -222,13 +246,14 @@ class MultiCheckboxInput(Input): super().__init__(id, label, infotext=infotext) self.options = options - def render_input(self, value): - return "".join(self.render_checkbox(o, value) for o in self.options) + def render_input(self, value, errors): + # TODO display errors + return "".join(self.render_checkbox(o, value, errors) for o in self.options) def checkbox_id(self, option): return "{0}-{1}".format(self.id, option.value) - def render_checkbox(self, option, value): + def render_checkbox(self, option, value, errors): return """
@@ -238,7 +263,7 @@ class MultiCheckboxInput(Input):
""".format( id=self.checkbox_id(option), - classes=self.input_classes(), + classes=self.input_classes(errors), checked="checked" if option.value in value else "", checkboxText=option.text, disabled="disabled" if self.disabled else "", @@ -251,8 +276,11 @@ class MultiCheckboxInput(Input): return {self.id: [o.value for o in self.options if in_response(o)]} - def input_classes(self): - return " ".join(["form-check", "form-control-sm"]) + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) class ServicesCheckboxInput(MultiCheckboxInput): @@ -286,14 +314,16 @@ class DropdownInput(Input): self.options = options super().__init__(id, label, infotext=infotext, converter=converter) - def render_input(self, value): + def render_input(self, value, errors): return """ + {errors} """.format( - classes=self.input_classes(), + classes=self.input_classes(errors), id=self.id, options=self.render_options(value), disabled="disabled" if self.disabled else "", + errors=self.render_errors(errors), ) def render_options(self, value): @@ -329,13 +359,13 @@ class ExponentialInput(Input): def defaultConverter(self): return IntConverter() - def input_properties(self, value): - props = super().input_properties(value) + def input_properties(self, value, errors): + props = super().input_properties(value, errors) props["type"] = "number" props["step"] = "any" return props - def render_input(self, value): + def render_input(self, value, errors): append = """
""".format( id="{}-{}".format(self.id, stage), - classes=self.input_classes(), + classes=self.input_classes(errors), extra_classes=extra_classes, disabled="disabled" if self.disabled else "", options="".join( @@ -203,7 +204,7 @@ class SchedulerInput(Input): ), ) - def render_static_entires(self, value): + def render_static_entires(self, value, errors): def render_time_inputs(v): values = ["{}:{}".format(x[0:2], x[2:4]) for x in [v[0:4], v[5:9]]] return '
-
'.join( @@ -211,7 +212,7 @@ class SchedulerInput(Input): """.format( id="{}-{}-{}".format(self.id, "time", "start" if i == 0 else "end"), - classes=self.input_classes(), + classes=self.input_classes(errors), disabled="disabled" if self.disabled else "", value=v, ) @@ -231,7 +232,7 @@ class SchedulerInput(Input):
""".format( time_inputs=render_time_inputs(slot), - select=self.render_profiles_select(value, slot, "profile"), + select=self.render_profiles_select(value, errors, slot, "profile"), ) for slot, entry in schedule.items() ) @@ -249,10 +250,10 @@ class SchedulerInput(Input): """.format( rows=rows, time_inputs=render_time_inputs("0000-0000"), - select=self.render_profiles_select("", "0000-0000", "profile"), + select=self.render_profiles_select("", errors, "0000-0000", "profile"), ) - def render_daylight_entries(self, value): + def render_daylight_entries(self, value, errors): return "".join( """
@@ -261,12 +262,12 @@ class SchedulerInput(Input):
""".format( name=name, - select=self.render_profiles_select(value, stage, stage, extra_classes="col-9"), + select=self.render_profiles_select(value, errors, stage, stage, extra_classes="col-9"), ) for stage, name in [("day", "Day"), ("night", "Night"), ("greyline", "Greyline")] ) - def render_input(self, value): + def render_input(self, value, errors): return """
diff --git a/owrx/form/validator.py b/owrx/form/validator.py new file mode 100644 index 0000000..165471f --- /dev/null +++ b/owrx/form/validator.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from owrx.form.error import ValidationError + + +class Validator(ABC): + @abstractmethod + def validate(self, key, value): + pass + + +class RequiredValidator(Validator): + def validate(self, key, value): + if value is None or value == "": + raise ValidationError(key, "Field is required") diff --git a/owrx/form/wsjt.py b/owrx/form/wsjt.py index 215b1b7..6fa7302 100644 --- a/owrx/form/wsjt.py +++ b/owrx/form/wsjt.py @@ -9,7 +9,7 @@ class Q65ModeMatrix(Input): def checkbox_id(self, mode, interval): return "{0}-{1}-{2}".format(self.id, mode.value, interval.value) - def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value): + def render_checkbox(self, mode: Q65Mode, interval: Q65Interval, value, errors): return """
@@ -18,16 +18,16 @@ class Q65ModeMatrix(Input):
""".format( - classes=self.input_classes(), + classes=self.input_classes(errors), id=self.checkbox_id(mode, interval), checked="checked" if "{}{}".format(mode.name, interval.value) in value else "", checkboxText="Mode {} interval {}s".format(mode.name, interval.value), disabled="" if interval.is_available(mode) and not self.disabled else "disabled", ) - def render_input(self, value): + def render_input(self, value, errors): checkboxes = "".join( - self.render_checkbox(mode, interval, value) for interval in Q65Interval for mode in Q65Mode + self.render_checkbox(mode, interval, value, errors) for interval in Q65Interval for mode in Q65Mode ) return """
@@ -37,8 +37,11 @@ class Q65ModeMatrix(Input): checkboxes=checkboxes ) - def input_classes(self): - return " ".join(["form-check", "form-control-sm"]) + def input_classes(self, error): + classes = ["form-check", "form-control-sm"] + if error: + classes.append("is-invalid") + return " ".join(classes) def parse(self, data): def in_response(mode, interval): @@ -59,7 +62,7 @@ class WsjtDecodingDepthsInput(Input): def defaultConverter(self): return JsonConverter() - def render_input(self, value): + def render_input(self, value, errors): def render_mode(m): return """ @@ -76,11 +79,11 @@ class WsjtDecodingDepthsInput(Input):
""".format( id=self.id, - classes=self.input_classes(), + classes=self.input_classes(errors), value=html.escape(value), options="".join(render_mode(m) for m in Modes.getAvailableModes() if isinstance(m, WsjtMode)), disabled="disabled" if self.disabled else "" ) - def input_classes(self): - return super().input_classes() + " wsjt-decoding-depths" + def input_classes(self, error): + return super().input_classes(error) + " wsjt-decoding-depths" diff --git a/owrx/source/__init__.py b/owrx/source/__init__.py index ef9f4e4..a5fb7db 100644 --- a/owrx/source/__init__.py +++ b/owrx/source/__init__.py @@ -488,19 +488,23 @@ class OptionalSection(Section): ) ) - def render_optional_inputs(self, data): + def render_optional_inputs(self, data, errors): return """ """.format( - inputs="".join(self.render_input(input, data) for input in self.optional_inputs) + inputs="".join(self.render_input(input, data, errors) for input in self.optional_inputs) ) - def render_inputs(self, data): - return super().render_inputs(data) + self.render_optional_select() + self.render_optional_inputs(data) + def render_inputs(self, data, errors): + return ( + super().render_inputs(data, errors) + + self.render_optional_select() + + self.render_optional_inputs(data, errors) + ) - def render(self, data): + def render(self, data, errors): indexed_inputs = {input.id: input for input in self.inputs} visible_keys = set(self.mandatory + [k for k in self.optional if k in data]) optional_keys = set(k for k in self.optional if k not in data) @@ -512,15 +516,15 @@ class OptionalSection(Section): for input in self.optional_inputs: input.setRemovable() input.setDisabled() - return super().render(data) + return super().render(data, errors) def parse(self, data): - data = super().parse(data) + data, errors = super().parse(data) # remove optional keys if they have been removed from the form for k in self.optional: if k not in data: data[k] = None - return data + return data, errors class SdrDeviceDescription(object): @@ -542,6 +546,7 @@ class SdrDeviceDescription(object): return True except SdrDeviceDescriptionMissing: return False + return [module_name for _, module_name, _ in pkgutil.walk_packages(__path__) if has_description(module_name)] def getDeviceInputs(self) -> List[Input]: