# -*- mode: python -*- # -*- coding: utf-8 -*- """Simple form generator/validator E. Viennet 2005 - 2008 v 1.3 (python3) """ import html import re import flask_wtf import wtforms from app import log from app.scodoc.sco_exceptions import ScoInvalidCSRF import app.scodoc.sco_utils as scu # re validant dd/mm/yyyy DMY_REGEXP = re.compile( r"^(?:(?:31(\/|-|\.)(?:0?[13578]|1[02]))\1|(?:(?:29|30)(\/|-|\.)(?:0?[13-9]|1[0-2])\2))(?:(?:1[6-9]|[2-9]\d)?\d{2})$|^(?:29(\/|-|\.)0?2\3(?:(?:(?:1[6-9]|[2-9]\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00))))$|^(?:0?[1-9]|1\d|2[0-8])(\/|-|\.)(?:(?:0?[1-9])|(?:1[0-2]))\4(?:(?:1[6-9]|[2-9]\d)?\d{2})$" ) def TrivialFormulator( form_url, values, formdescription=(), initvalues=None, method="post", enctype=None, submitlabel="OK", name=None, formid="tf", form_attrs="", cssclass="", cancelbutton=None, submitbutton=True, submitbuttonattributes=None, top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_head_markup="", html_foot_markup="", readonly=False, is_submitted=False, title="", after_table="", before_table="{title}", hidden_args: list[tuple] | None = None, ): """ form_url : URL for this form initvalues : dict giving default values values : dict with all HTML form variables (may start empty) is_submitted: handle form as if already submitted Returns (status, HTML form, values) status = 0 (html to display), 1 (ok, validated values in "values") -1 cancel (if cancelbutton specified) HTML form: html string (form to insert in your web page) values: None or, when the form is submitted and correctly filled, a dictionnary with the requeted values. formdescription: sequence [ (field, description), ... ] where description is a dict with following (optional) keys: default : default value for this field ('') title : text titre (default to field name) allow_null : if true, field can be left empty (default true) type : 'string', 'int', 'float' (default to string), 'list' (only for hidden) readonly : default False. if True, no form element, display current value. convert_numbers: convert int and float values (from string) allowed_values : list of possible values (default: any value) validator : function validating the field (called with (value,field)). min_value : minimum value (for floats and ints) max_value : maximum value (for floats and ints) explanation: text string to display next the input widget title_buble: help bubble on field title (needs bubble.js or equivalent) comment : comment, showed under input widget withcheckbox: if true, place a checkbox at the left of the input elem. Checked items will be returned in 'tf-checked' attributes: a liste of strings to put in the HTML form element template: HTML template for element HTML elements: input_type : 'text', 'textarea', 'password', 'radio', 'menu', 'checkbox', 'hidden', 'separator', 'table_separator', 'file', 'date', 'datedmy' (avec validation), 'boolcheckbox', 'text_suggest', 'color' (default text) size : text field width rows, cols: textarea geometry labels : labels for radio or menu lists (associated to allowed_values) vertical: for checkbox; if true, vertical layout disabled_items: for checkbox, dict such that disabled_items[i] true if disabled checkbox To use text_suggest elements, one must: - specify options in text_suggest_options (a dict) - HTML page must load JS AutoSuggest.js and CSS autosuggest_inquisitor.css """ method = method.lower() if method == "get": enctype = None t = TF( form_url, values, formdescription, initvalues or {}, method, enctype, submitlabel, name, formid, form_attrs=form_attrs, cssclass=cssclass, cancelbutton=cancelbutton, submitbutton=submitbutton, submitbuttonattributes=submitbuttonattributes or [], top_buttons=top_buttons, bottom_buttons=bottom_buttons, html_head_markup=html_head_markup, html_foot_markup=html_foot_markup, readonly=readonly, is_submitted=is_submitted, title=title, after_table=after_table, before_table=before_table, hidden_args=hidden_args, ) form = t.getform() if t.canceled(): res = -1 elif t.submitted() and t.result: res = 1 else: res = 0 return res, form, t.result class TF(object): def __init__( self, form_url, values, formdescription=None, initvalues=None, method="POST", enctype=None, submitlabel="OK", name=None, formid="tf", form_attrs="", cssclass="", cancelbutton=None, submitbutton=True, submitbuttonattributes=None, top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_head_markup="", # html snippet put at the beginning, just before the table html_foot_markup="", # html snippet put at the end, just after the table readonly=False, is_submitted=False, title="", after_table="", before_table="{title}", hidden_args: list[tuple] | None = None, ): self.form_url = form_url self.values = values.copy() self.formdescription = list(formdescription or []) self.initvalues = initvalues or {} self.method = method self.enctype = enctype self.submitlabel = submitlabel if name: self.name = name else: self.name = formid # 'tf' self.formid = formid self.form_attrs = form_attrs self.cssclass = cssclass self.cancelbutton = cancelbutton self.submitbutton = submitbutton self.submitbuttonattributes = submitbuttonattributes or [] self.top_buttons = top_buttons self.bottom_buttons = bottom_buttons self.html_head_markup = html_head_markup self.html_foot_markup = html_foot_markup self.title = title self.after_table = after_table self.before_table = before_table self.hidden_args = hidden_args or [] self.readonly = readonly self.result = None self.is_submitted = is_submitted if readonly: self.top_buttons = self.bottom_buttons = False self.cssclass += " readonly" def submitted(self): "true if form has been submitted" if self.is_submitted: return True form_submitted = self.values.get(f"{self.formid}_submitted", False) if form_submitted: self.check_csrf() return form_submitted def check_csrf(self): """check token for POST forms. Raises ScoInvalidCSRF on failure. """ if self.method == "post": token = self.values.get("csrf_token") try: flask_wtf.csrf.validate_csrf(token) except wtforms.validators.ValidationError as exc: log(f"Form.check_csrf: invalid CSRF token\n{exc.args}") raise ScoInvalidCSRF() from exc def canceled(self): "true if form has been canceled" return self.values.get(f"{self.formid}_cancel", False) def getform(self): "return HTML form" R = [] msg = None self.setdefaultvalues() if self.submitted() and not self.readonly: msg = self.checkvalues() # display error message R.append(tf_error_message(msg)) # form or view if self.readonly: R = R + self._ReadOnlyVersion(self.formdescription) else: R = R + self._GenForm() # return "\n".join(R) __str__ = getform __repr__ = getform def setdefaultvalues(self): "set default values and convert numbers to strings" for field, descr in self.formdescription: # special case for boolcheckbox if descr.get("input_type", None) == "boolcheckbox" and self.submitted(): if field not in self.values: self.values[field] = 0 else: self.values[field] = 1 if field not in self.values: if (descr.get("input_type", None) == "checkbox") and self.submitted(): # aucune case cochée self.values[field] = [] else: if "default" in descr: # first: default in form description self.values[field] = descr["default"] else: # then: use initvalues dict self.values[field] = self.initvalues.get(field, "") if self.values[field] is None: self.values[field] = "" # convert numbers, except ids if field.endswith("id") and self.values[field]: # enforce integer ids: try: self.values[field] = int(self.values[field]) except ValueError: pass elif isinstance(self.values[field], (int, float)): self.values[field] = str(self.values[field]) # if "tf-checked" not in self.values: if self.submitted(): # si rien n'est coché, tf-checked n'existe plus dans la reponse self.values["tf-checked"] = [] else: self.values["tf-checked"] = self.initvalues.get("tf-checked", []) self.values["tf-checked"] = [str(x) for x in self.values["tf-checked"]] def checkvalues(self): "check values. Store .result and returns msg" ok = 1 msg = [] for field, descr in self.formdescription: val = self.values[field] # do not check "unckecked" items if descr.get("withcheckbox", False): if not field in self.values["tf-checked"]: continue # null values allow_null = descr.get("allow_null", True) if not allow_null: if val is None or (isinstance(val, str) and not val.strip()): msg.append( f"Le champ '{descr.get('title', field)}' doit être renseigné" ) ok = 0 elif val in ("", None): continue # allowed empty field, skip # type typ = descr.get("type", "text") # Option pour striper les chaînes if isinstance(val, str) and typ == "text" and descr.get("strip", False): val = val.strip() self.values[field] = val if val != "" and val is not None: # check only non-null values if typ[:3] == "int": try: val = int(val) self.values[field] = val except ValueError: msg.append( f"La valeur du champ '{field}' doit être un nombre entier" ) ok = 0 elif typ in ("float", "real"): self.values[field] = self.values[field].replace(",", ".") try: val = float(val.replace(",", ".")) # allow , self.values[field] = val except ValueError: msg.append(f"La valeur du champ {field}' doit être un nombre") ok = 0 if ( ok and (typ[:3] == "int" or typ == "float" or typ == "real") and val != "" and val is not None ): if "min_value" in descr and self.values[field] < descr["min_value"]: msg.append( "La valeur (%d) du champ '%s' est trop petite (min=%s)" % (val, field, descr["min_value"]) ) ok = 0 if "max_value" in descr and self.values[field] > descr["max_value"]: msg.append( "La valeur (%s) du champ '%s' est trop grande (max=%s)" % (val, field, descr["max_value"]) ) ok = 0 if typ[:3] == "int": if not scu.DB_MIN_INT <= self.values[field] <= scu.DB_MAX_INT: msg.append( f"Le champ '{field}' est a une valeur hors limite" ) ok = 0 elif typ in ("float", "real"): if not ( scu.DB_MIN_FLOAT <= self.values[field] <= scu.DB_MAX_FLOAT ): msg.append( f"Le champ '{field}' est a une valeur hors limite" ) ok = 0 if ok and "max_length" in descr and isinstance(self.values[field], str): if len(self.values[field]) > descr["max_length"]: msg.append( "Le champ '%s' est trop long (max %d caractères)" % (field, descr["max_length"]) ) ok = 0 # allowed values if "allowed_values" in descr: if descr.get("input_type", None) == "checkbox": # for checkboxes, val is a list for v in val: if not v in descr["allowed_values"]: msg.append( "valeur invalide (%s) pour le champ '%s'" % (val, field) ) ok = 0 elif descr.get("input_type", None) == "boolcheckbox": pass elif not val in descr["allowed_values"]: msg.append("valeur invalide (%s) pour le champ '%s'" % (val, field)) ok = 0 if "validator" in descr: try: valid = descr["validator"](val, field) except Exception: valid = False if not valid: msg.append("valeur invalide (%s) pour le champ '%s'" % (val, field)) ok = 0 elif descr.get("input_type") == "datedmy": if not DMY_REGEXP.match(val): msg.append("valeur invalide (%s) pour la date '%s'" % (val, field)) ok = 0 # boolean checkbox if descr.get("input_type", None) == "boolcheckbox": if int(val): self.values[field] = True else: self.values[field] = False if descr.get("convert_numbers", False): if typ[:3] == "int": try: self.values[field] = int(self.values[field]) except ValueError: msg.append( f"valeur invalide ({self.values[field]}) pour le champ {field}" ) ok = False elif typ == "float" or typ == "real": try: self.values[field] = float(self.values[field].replace(",", ".")) except ValueError: msg.append( f"valeur invalide ({self.values[field]}) pour le champ {field}" ) ok = False if ok: self.result = self.values else: self.result = None return msg def _GenForm(self, method="", enctype=None, form_url=""): values = self.values add_no_enter_js = False # add JS function to prevent 'enter' -> submit # form template # default template for each input element itemtemplate = """ %(label)s%(elem)s """ hiddenitemtemplate = "%(elem)s" separatortemplate = '%(label)s' # ---- build form buttons_markup = """
""" if self.submitbutton: buttons_markup += f"""""" if self.cancelbutton: buttons_markup += f""" """ buttons_markup += "
" R = [] suggest_js = [] if self.enctype is None: if self.method == "post": enctype = "multipart/form-data" else: enctype = "application/x-www-form-urlencoded" if self.cssclass: klass = ' class="%s"' % self.cssclass else: klass = "" name = self.name R.append( '
' % ( self.form_url, self.method, self.formid, enctype, name, klass, self.form_attrs, ) ) if self.method == "post": R.append( f"""""" ) R.append(f"""""") for k, v in self.hidden_args: R.append(f"""""") if self.top_buttons: R.append(buttons_markup + "

") R.append(self.before_table.format(title=self.title)) R.append(self.html_head_markup) R.append('') for field, descr in self.formdescription: if descr.get("readonly", False): R.append(self._ReadOnlyElement(field, descr)) continue wid = self.name + "_" + field size = descr.get("size", 12) rows = descr.get("rows", 5) cols = descr.get("cols", 60) title = descr.get("title", field.capitalize()) title_bubble = descr.get("title_bubble", None) withcheckbox = descr.get("withcheckbox", False) input_type = descr.get("input_type", "text") item_dom_id = descr.get("dom_id", "") if item_dom_id: item_dom_attr = f' id="{item_dom_id}"' else: item_dom_attr = "" # choix du template etempl = descr.get("template", None) if etempl is None: if input_type == "hidden": etempl = hiddenitemtemplate elif input_type == "separator": etempl = separatortemplate R.append(etempl % {"label": title, "item_dom_attr": item_dom_attr}) continue elif input_type == "table_separator": etempl = "" # Table ouverte ? if len([p for p in R if " len( [p for p in R if "{self.after_table}""") R.append( f"""{self.before_table.format(title=descr.get("title", ""))}
""" ) else: etempl = itemtemplate lab = [] lem = [] if withcheckbox and input_type != "hidden": if field in values["tf-checked"]: checked = 'checked="checked"' else: checked = "" lab.append( '' % ("tf-checked", field, checked) ) if title_bubble: lab.append( '%s' % (title_bubble, title) ) else: lab.append(title) # attribs = " ".join(descr.get("attributes", [])) if ( withcheckbox and not checked ) or not descr.get( # desactive les element non coches: "enabled", True ): attribs += ' disabled="true"' # if input_type == "text": lem.append( '') % values) elif input_type == "password": lem.append( '') % values) elif input_type == "radio": labels = descr.get("labels", descr["allowed_values"]) for i in range(len(labels)): if descr["allowed_values"][i] == values[field]: checked = 'checked="checked"' else: checked = "" lem.append( '%s' % ( field, descr["allowed_values"][i], checked, attribs, labels[i], ) ) elif input_type == "menu": lem.append('") elif input_type == "checkbox" or input_type == "boolcheckbox": if input_type == "checkbox": labels = descr.get("labels", descr["allowed_values"]) else: # boolcheckbox labels = [""] descr["allowed_values"] = ["0", "1"] vertical = descr.get("vertical", False) disabled_items = descr.get("disabled_items", {}) if vertical: lem.append("
") for i in range(len(labels)): # pylint: disable=consider-using-enumerate if input_type == "checkbox": if ( values[field] and descr["allowed_values"][i] in values[field] ): checked = 'checked="checked"' else: checked = "" else: # boolcheckbox if values[field] == "True": v = True elif values[field] == "False": v = False else: try: v = int(values[field]) except (ValueError, KeyError): v = False if v: checked = 'checked="checked"' else: checked = "" if vertical: lem.append("") if vertical: lem.append("
") if disabled_items.get(i, False): disab = 'disabled="1"' ilab = ( '' + labels[i] + " (non modifiable)" ) else: disab = "" ilab = "" + labels[i] + "" lem.append( '%s' % ( field, descr["allowed_values"][i], attribs, disab, checked, ilab, ) ) if vertical: lem.append("
") elif input_type == "textarea": lem.append( '' % (field, wid, rows, cols, attribs, values[field]) ) elif input_type == "hidden": if descr.get("type", "") == "list": for v in values[field]: lem.append( '' % (field, v, attribs) ) else: lem.append( '' % (field, wid, values[field], attribs) ) elif (input_type == "separator") or (input_type == "table_separator"): pass elif input_type == "file": lem.append( '' % (field, size, values[field], attribs) ) elif ( input_type == "date" or input_type == "datedmy" ): # JavaScript widget for date input lem.append( '' % (field, values[field]) ) elif input_type == "time": # JavaScript widget for date input lem.append( f"""""" ) elif input_type == "text_suggest": lem.append( '') % values) suggest_js.append( f"""var {field}_opts = {dict2js(descr.get("text_suggest_options", {}))}; var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); """ ) elif input_type == "color": lem.append( '') % values) else: raise ValueError(f"unkown input_type for form ({input_type})!") explanation = descr.get("explanation", "") if explanation: lem.append(f"""{explanation}""") comment = descr.get("comment", "") if comment: if (input_type != "checkbox") and (input_type != "boolcheckbox"): lem.append("
") lem.append(f"""{comment}""") R.append( etempl % { "label": "\n".join(lab), "elem": "\n".join(lem), "item_dom_attr": item_dom_attr, } ) R.append("") R.append(self.after_table) R.append(self.html_foot_markup) if self.bottom_buttons: R.append("
" + buttons_markup) if add_no_enter_js: R.append( """ """ ) # enter_focus_next, ne focus que les champs text if suggest_js: # nota: formid is currently ignored # => only one form with text_suggest field on a page. R.append( """""" % "\n".join(suggest_js) ) # Javascript common to all forms: R.append( """""" ) R.append("
") return R def _ReadOnlyElement(self, field, descr): "Generate HTML for an element, read-only" R = [] title = descr.get("title", field.capitalize()) input_type = descr.get("input_type", "text") klass = descr.get("cssclass", "") klass = " " + klass if input_type == "hidden": return "" R.append('' % klass) if input_type == "separator": # separator R.append('%s' % title) elif input_type != "table_separator": R.append('' % klass) R.append("%s" % title) R.append('' % klass) if input_type in ("text", "text_suggest", "color", "datedmy"): R.append(("%(" + field + ")s") % self.values) elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"): if input_type == "boolcheckbox": labels = descr.get( "labels", descr.get("allowed_values", ["non", "oui"]) ) _val = self.values[field] if isinstance(_val, bool): bool_val = 1 if _val else 0 elif _val == "False": bool_val = 0 elif _val: bool_val = 1 else: bool_val = 0 R.append(labels[bool_val]) if bool_val: R.append(f'') else: labels = descr.get("labels", descr["allowed_values"]) for i in range(len(labels)): if str(descr["allowed_values"][i]) == str(self.values[field]): R.append('%s' % labels[i]) elif input_type == "textarea": R.append( '
%s
' % html.escape(self.values[field]) ) elif ( input_type == "separator" or input_type == "hidden" or input_type == "table_separator" ): pass elif input_type == "file": R.append("'%s'" % self.values[field]) else: raise ValueError(f"unkown input_type for form ({input_type})!") explanation = descr.get("explanation", "") if explanation: R.append('%s' % explanation) R.append("") return "\n".join(R) def _ReadOnlyVersion(self, formdescription): "Generate HTML for read-only view of the form" R = [''] for field, descr in formdescription: R.append(self._ReadOnlyElement(field, descr)) R.append("
") return R def dict2js(d): """convert Python dict to JS code""" r = [] for k in d: v = d[k] if isinstance(v, bool): if v: v = "true" else: v = "false" elif isinstance(v, str): # ne marchera pas en python2 v = '"' + v + '"' r.append("%s: %s" % (k, v)) return "{" + ",\n".join(r) + "}" def tf_error_message(msg): """html for form error message""" if not msg: return "" if isinstance(msg, str): msg = [msg] return ( '' % '
  • '.join(msg) )