# -*- 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 = """