# -*- mode: python -*- # -*- coding: utf-8 -*- """Simple form generator/validator E. Viennet 2005 - 2008 v 1.3 (python3) """ import html import re 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={}, method="post", enctype=None, submitlabel="OK", name=None, formid="tf", form_attrs="", cssclass="", cancelbutton=None, submitbutton=True, submitbuttonattributes=[], top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_foot_markup="", readonly=False, is_submitted=False, title="", after_table="", before_table="{title}", ): """ 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 - bodyOnLoad must call JS function init_tf_form(formid) """ method = method.lower() if method == "get": enctype = None t = TF( form_url, values, formdescription, initvalues, method, enctype, submitlabel, name, formid, form_attrs=form_attrs, cssclass=cssclass, cancelbutton=cancelbutton, submitbutton=submitbutton, submitbuttonattributes=submitbuttonattributes, top_buttons=top_buttons, bottom_buttons=bottom_buttons, html_foot_markup=html_foot_markup, readonly=readonly, is_submitted=is_submitted, title=title, after_table=after_table, before_table=before_table, ) 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=[], initvalues={}, method="POST", enctype=None, submitlabel="OK", name=None, formid="tf", form_attrs="", cssclass="", cancelbutton=None, submitbutton=True, submitbuttonattributes=[], top_buttons=False, # place buttons at top of form bottom_buttons=True, # buttons after form html_foot_markup="", # html snippet put at the end, just after the table readonly=False, is_submitted=False, title="", after_table="", before_table="{title}", ): self.form_url = form_url self.values = values.copy() self.formdescription = list(formdescription) self.initvalues = initvalues 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 self.top_buttons = top_buttons self.bottom_buttons = bottom_buttons self.html_foot_markup = html_foot_markup self.title = title self.after_table = after_table self.before_table = before_table 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 return self.values.get("%s_submitted" % self.formid, False) def canceled(self): "true if form has been canceled" return self.values.get("%s_cancel" % self.formid, 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 == "" or val == None: msg.append( "Le champ '%s' doit être renseigné" % descr.get("title", field) ) ok = 0 elif val == "" or val == None: continue # allowed empty field, skip # type typ = descr.get("type", "string") 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( "La valeur du champ '%s' doit être un nombre entier" % field ) ok = 0 elif typ == "float" or typ == "real": self.values[field] = self.values[field].replace(",", ".") try: val = float(val.replace(",", ".")) # allow , self.values[field] = val except ValueError: msg.append( "La valeur du champ '%s' doit être un nombre" % field ) ok = 0 if ( ok and (typ[:3] == "int" or typ == "float" or typ == "real") and val != "" and val != 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 == "float" or typ == "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 (typ[:3] == "str") and "max_length" in descr: 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 champs {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 champs {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 = """<tr%(item_dom_attr)s> <td class="tf-fieldlabel">%(label)s</td><td class="tf-field">%(elem)s</td> </tr> """ hiddenitemtemplate = "%(elem)s" separatortemplate = '<tr%(item_dom_attr)s><td colspan="2">%(label)s</td></tr>' # ---- build form buttons_markup = "" if self.submitbutton: buttons_markup += ( '<input type="submit" name="%s_submit" id="%s_submit" value="%s" %s>' % ( self.formid, self.formid, self.submitlabel, " ".join(self.submitbuttonattributes), ) ) if self.cancelbutton: buttons_markup += ( ' <input type="submit" name="%s_cancel" id="%s_cancel" value="%s">' % (self.formid, self.formid, self.cancelbutton) ) 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( '<form action="%s" method="%s" id="%s" enctype="%s" name="%s" %s %s>' % ( self.form_url, self.method, self.formid, enctype, name, klass, self.form_attrs, ) ) R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid) if self.top_buttons: R.append(buttons_markup + "<p></p>") R.append(self.before_table.format(title=self.title)) R.append('<table class="tf">') 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 "<table" in p]) > len( [p for p in R if "</table" in p] ): R.append(f"""</table>{self.after_table}""") R.append( f"""{self.before_table.format(title=descr.get("title", ""))}<table class="tf">""" ) else: etempl = itemtemplate lab = [] lem = [] if withcheckbox and input_type != "hidden": if field in values["tf-checked"]: checked = 'checked="checked"' else: checked = "" lab.append( '<input type="checkbox" name="%s:list" value="%s" onclick="tf_enable_elem(this)" %s>' % ("tf-checked", field, checked) ) if title_bubble: lab.append( '<a class="discretelink" href="" title="%s">%s</a>' % (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( '<input type="text" name="%s" size="%d" id="%s" %s' % (field, size, wid, attribs) ) if descr.get("return_focus_next", False): # and nextitemname: # JS code to focus on next element on 'enter' key # ceci ne marche que pour desactiver enter sous IE (pas Firefox) # lem.append('''onKeyDown="if(event.keyCode==13){ # event.cancelBubble = true; event.returnValue = false;}"''') lem.append('onkeypress="return enter_focus_next(this, event);"') add_no_enter_js = True # lem.append('onchange="document.%s.%s.focus()"'%(name,nextitemname)) # lem.append('onblur="document.%s.%s.focus()"'%(name,nextitemname)) lem.append(('value="%(' + field + ')s" >') % values) elif input_type == "password": lem.append( '<input type="password" name="%s" id="%s" size="%d" %s' % (field, wid, size, attribs) ) lem.append(('value="%(' + field + ')s" >') % 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( '<input type="radio" name="%s" value="%s" %s %s>%s</input>' % ( field, descr["allowed_values"][i], checked, attribs, labels[i], ) ) elif input_type == "menu": lem.append('<select name="%s" id="%s" %s>' % (field, wid, attribs)) labels = descr.get("labels", descr["allowed_values"]) for i in range(len(labels)): if str(descr["allowed_values"][i]) == str(values[field]): selected = "selected" else: selected = "" lem.append( '<option value="%s" %s>%s</option>' % (descr["allowed_values"][i], selected, labels[i]) ) lem.append("</select>") 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("<table>") 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("<tr><td>") if disabled_items.get(i, False): disab = 'disabled="1"' ilab = ( '<span class="tf-label-disabled">' + labels[i] + "</span> <em>(non modifiable)</em>" ) else: disab = "" ilab = "<span>" + labels[i] + "</span>" lem.append( '<input type="checkbox" name="%s:list" value="%s" %s %s %s>%s</input>' % ( field, descr["allowed_values"][i], attribs, disab, checked, ilab, ) ) if vertical: lem.append("</tr></td>") if vertical: lem.append("</table>") elif input_type == "textarea": lem.append( '<textarea name="%s" id="%s" rows="%d" cols="%d" %s>%s</textarea>' % (field, wid, rows, cols, attribs, values[field]) ) elif input_type == "hidden": if descr.get("type", "") == "list": for v in values[field]: lem.append( '<input type="hidden" name="%s:list" value="%s" %s >' % (field, v, attribs) ) else: lem.append( '<input type="hidden" name="%s" id="%s" value="%s" %s >' % (field, wid, values[field], attribs) ) elif (input_type == "separator") or (input_type == "table_separator"): pass elif input_type == "file": lem.append( '<input type="file" name="%s" size="%s" value="%s" %s>' % (field, size, values[field], attribs) ) elif ( input_type == "date" or input_type == "datedmy" ): # JavaScript widget for date input lem.append( '<input type="text" name="%s" size="10" value="%s" class="datepicker">' % (field, values[field]) ) elif input_type == "text_suggest": lem.append( '<input type="text" name="%s" id="%s" size="%d" %s' % (field, field, size, attribs) ) lem.append(('value="%(' + field + ')s" />') % 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( '<input type="color" name="%s" id="%s" %s' % (field, field, attribs) ) lem.append(('value="%(' + field + ')s" >') % values) else: raise ValueError(f"unkown input_type for form ({input_type})!") explanation = descr.get("explanation", "") if explanation: lem.append(f"""<span class="tf-explanation">{explanation}</span>""") comment = descr.get("comment", "") if comment: if (input_type != "checkbox") and (input_type != "boolcheckbox"): lem.append("<br>") lem.append(f"""<span class="tf-comment">{comment}</span>""") R.append( etempl % { "label": "\n".join(lab), "elem": "\n".join(lem), "item_dom_attr": item_dom_attr, } ) R.append("</table>") R.append(self.after_table) R.append(self.html_foot_markup) if self.bottom_buttons: R.append("<br>" + buttons_markup) if add_no_enter_js: R.append( """<script type="text/javascript"> function enter_focus_next (elem, event) { var cod = event.keyCode ? event.keyCode : event.which ? event.which : event.charCode; var enter = false; if (event.keyCode == 13) enter = true; if (event.which == 13) enter = true; if (event.charCode == 13) enter = true; if (enter) { var focused = false; var i; for (i = 0; i < elem.form.elements.length; i++) if (elem == elem.form.elements[i]) break; i = i + 1; while (i < elem.form.elements.length) { if ((elem.form.elements[i].type == "text") && (!(elem.form.elements[i].disabled)) && ($(elem.form.elements[i]).is(':visible'))) { elem.form.elements[i].focus(); focused = true; break; } i = i + 1; } if (!focused) { elem.blur(); } return false; } else return true; }</script> """ ) # 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( """<script type="text/javascript"> function init_tf_form(formid) { %s } </script>""" % "\n".join(suggest_js) ) # Javascript common to all forms: R.append( """<script type="text/javascript"> // controle par la checkbox function tf_enable_elem(checkbox) { var oid = checkbox.value; if (oid) { var elem = document.getElementById(oid); if (elem) { if (checkbox.checked) { elem.disabled = false; } else { elem.disabled = true; } } } } // Selections etendues avec shift (use jquery.field) $('input[name="tf-checked:list"]').createCheckboxRange(); </script>""" ) R.append("</form>") 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('<tr class="tf-ro-tr%s">' % klass) if input_type == "separator": # separator R.append('<td colspan="2">%s' % title) elif input_type != "table_separator": R.append('<td class="tf-ro-fieldlabel%s">' % klass) R.append("%s</td>" % title) R.append('<td class="tf-ro-field%s">' % 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('<input type="hidden" name="%s" value="1">' % field) 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('<span class="tf-ro-value">%s</span>' % labels[i]) elif input_type == "textarea": R.append( '<div class="tf-ro-textarea">%s</div>' % 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("unkown input_type for form (%s)!" % input_type) explanation = descr.get("explanation", "") if explanation: R.append('<span class="tf-explanation">%s</span>' % explanation) R.append("</td></tr>") return "\n".join(R) def _ReadOnlyVersion(self, formdescription): "Generate HTML for read-only view of the form" R = ['<table class="tf-ro">'] for (field, descr) in formdescription: R.append(self._ReadOnlyElement(field, descr)) R.append("</table>") 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 ( '<ul class="tf-msg"><li class="tf-msg error-message">%s</li></ul>' % '</li><li class="tf-msg tf-msg error-message">'.join(msg) )