terom@34: """ terom@34: Form rendering and handling terom@34: """ terom@34: terom@34: from svv.html import tags terom@34: from svv import database as db terom@34: terom@34: import collections terom@34: import datetime terom@34: import logging terom@34: terom@34: try : terom@34: # Python2.6 stdlib terom@34: import json terom@34: terom@34: except ImportError : terom@34: # simplejson, API should be similar terom@34: import simplejson as json terom@34: terom@34: terom@34: log = logging.getLogger('svv.forms') terom@34: terom@34: class FormError (Exception) : terom@34: """ terom@34: A user-level error in a form field terom@34: """ terom@34: terom@34: def __init__ (self, field, value, error) : terom@34: """ terom@34: field - name of field with error terom@34: value - the errenous value in the form that we recieved it terom@34: may be None if it was the *lack* of a value that caused the issue terom@34: error - descriptive text for user terom@34: """ terom@34: terom@34: self.field = field terom@34: self.value = value terom@34: terom@34: super(FormError, self).__init__(error) terom@34: terom@34: class BaseForm (object) : terom@48: # processed POST data terom@34: data = None terom@34: terom@48: def __init__ (self) : terom@34: """ terom@48: Setup state. terom@48: terom@48: Note that a Form is stateful, and should only be used for one form transaction. terom@34: """ terom@34: terom@34: # accumulated errors terom@34: self.errors = collections.defaultdict(list) terom@34: terom@34: def defaults (self) : terom@34: """ terom@34: Update our attributes with default values terom@34: """ terom@34: terom@34: raise NotImplementedError() terom@34: terom@34: def fail_field (self, form_error, field=None) : terom@34: """ terom@34: Mark the field mentioned inside the given FormError as failed. terom@34: terom@34: form_error - the FormError to store terom@34: field - the name of the field to store the error under, if not the same as in form_error terom@34: """ terom@34: terom@34: field = field or form_error.field terom@34: terom@34: log.warn("Marking field %s as failed: %s", field, form_error) terom@34: terom@34: self.errors[field].append(form_error) terom@34: terom@34: def build_name_for_armed_input (self, name) : terom@34: """ terom@34: Return the name used for the checkbox associated with the named armed text input terom@34: """ terom@34: terom@34: return name + '_enabled' terom@34: terom@34: terom@34: def process_raw_field (self, name, default=None, required=None) : terom@34: """ terom@34: Process a generic incoming data field. terom@34: terom@34: default - value to return if no value was present terom@34: required - raise a FormError if no value present terom@34: terom@34: Returns the value as a str, or default terom@34: """ terom@34: terom@34: if name in self.data : terom@34: return self.data[name] terom@34: terom@34: elif required : terom@34: raise FormError(name, None, "Required field") terom@34: terom@34: else : terom@34: return default terom@34: terom@34: def process_text_field (self, name, default=None, required=None, strip=True) : terom@34: """ terom@34: Process a generic incoming string field. terom@34: terom@34: Trims extra whitespace from around the value, unless strip=False is given. terom@34: terom@34: Returns the value as unicode, or default. terom@34: """ terom@34: terom@34: value = self.process_raw_field(name, required=required) terom@34: terom@34: if value is None : terom@34: return default terom@34: terom@34: try : terom@34: # XXX: decode somehow, or can werkzeug handle that? terom@34: value = unicode(value) terom@34: terom@34: except UnicodeDecodeError : terom@34: raise FormError(name, value, "Failed to decode Unicode characters") terom@34: terom@34: if strip : terom@34: value = value.strip() terom@34: terom@34: return value terom@34: terom@34: def process_integer_field (self, name, default=None, required=None) : terom@34: """ terom@34: Process a generic incoming int field. terom@34: terom@48: If the field is empty, uses the default value. terom@48: terom@34: Returns the value as int, or default. terom@34: """ terom@34: terom@34: value = self.process_raw_field(name, required=required) terom@48: terom@48: if not value: terom@48: # not included in POST, or empty terom@34: return default terom@34: terom@34: try : terom@34: return int(value) terom@34: terom@34: except ValueError : terom@34: raise FormError(name, value, "Must be a number") terom@34: terom@34: DATETIME_FORMAT = "%d.%m.%Y %H:%M" terom@34: terom@34: def process_datetime_field (self, name, default=None, required=None, format=DATETIME_FORMAT) : terom@34: """ terom@34: Process an incoming datetime field. terom@34: terom@34: Returns the value as datetime, or default. terom@34: """ terom@34: terom@34: value = self.process_raw_field(name, required=required) terom@34: terom@34: if value is None : terom@34: return default terom@34: terom@34: try : terom@34: return datetime.datetime.strptime(value, format) terom@34: terom@34: except ValueError, ex : terom@34: raise FormError(name, value, "Invalid date/time value: " + str(ex)) terom@34: terom@34: def process_checkbox_field (self, name, required=None) : terom@34: """ terom@34: Process an incoming checkbox input's value. terom@34: terom@34: Any non-empty/non-whitespace value will be accepted as True. terom@34: """ terom@34: terom@34: value = self.process_raw_field(name, required) terom@34: terom@34: if not value and required : terom@34: raise FormError(name, value, "Must be checked") terom@34: terom@34: elif value and value.strip() : terom@34: # checked terom@34: return True terom@34: terom@34: else : terom@34: # unchecked terom@34: return False terom@34: terom@34: def process_armed_text_field (self, name, required=None) : terom@34: """ terom@34: A text field that must be enabled by a checkbox before being used. terom@34: terom@34: If not enabled, returns False. Otherwise, text field value, and fails if empty but required. terom@34: """ terom@34: terom@34: checkbox_name = self.build_name_for_armed_input(name) terom@34: terom@34: if self.process_checkbox_field(checkbox_name) : terom@34: # use input value terom@34: return self.process_text_field(name, required=required) terom@34: terom@34: else : terom@34: # not selected terom@34: return False terom@48: terom@48: def process_action_field (self, name, required=None) : terom@48: """ terom@48: Given a form with multiple named submit buttons, checks if the given one was used to submit the form. terom@34: terom@48: If required, raises a FormError if the button wasn't pressed. terom@48: """ terom@48: terom@48: value = self.process_raw_field(name, required) terom@48: terom@48: if value and value.strip() : terom@48: # pressed terom@48: return True terom@48: terom@48: elif required : terom@48: raise FormError(name, None, "Must be pressed") terom@48: terom@48: else : terom@48: # some other submit was hit terom@48: return False terom@48: terom@48: def process_list_field (self, name, required=None, type=unicode) : terom@48: """ terom@48: Process an incoming multi-value field, returning the full list of values, converted using the given converter. terom@48: terom@48: Returns an empty list if no incoming values, and not rquired. terom@48: terom@48: XXX: doesn't strip() the values terom@48: """ terom@48: terom@48: if name not in self.data and required : terom@48: # fail terom@48: raise FormError(name, None, "No values given") terom@48: terom@48: elif name not in self.data : terom@48: # empty/default terom@48: return [] terom@48: terom@48: else : terom@48: # type'd list of values terom@48: # uses werkzeug's MultiDict API terom@48: return self.data.getlist(name, type) terom@34: terom@34: def process_multifield (self, table, id, fields) : terom@34: """ terom@34: Process a set of user-given field values for an object with an unique id, and some set of additional fields. terom@34: terom@34: If the id is given, look up the corresponding field values, and return those. terom@34: terom@34: If any of the fields are given, either look up a matching row, or create a new one, and return its id. terom@34: terom@34: Returns an (id_name, field_name, ...) N-tuple. terom@34: """ terom@34: terom@34: id_name, id_col, id_value = id terom@34: terom@34: if id_value : terom@34: # look up object from db terom@34: columns = [col for name, col, value in fields] terom@34: terom@34: sql = db.select(columns, (id_col == id_value)) terom@34: terom@34: for row in self.app.query(sql) : terom@34: # XXX: sanity-check row values vs our values terom@34: terom@34: # new values terom@34: fields = tuple( terom@34: (name, col, row[col]) for name, col, value in fields terom@34: ) terom@34: terom@34: # ok, just use the first one terom@34: break terom@34: terom@34: else : terom@34: # not found! terom@34: raise FormError(id_name, id_value, "Item selected does not seem to exist") terom@34: terom@34: log.info("Lookup %s=%d -> %s", id_name, id_value, dict((name, value) for name, col, value in fields)) terom@34: terom@34: elif any(value for name, col, value in fields) : terom@34: # look up identical object from db? terom@34: sql = db.select([id_col], db.and_(*[(col == value) for name, col, value in fields])) terom@34: terom@34: for row in self.app.query(sql) : terom@34: if id_value : terom@34: log.warn("Duplicate %s=%d for %s", id_name, id_value, dict((name, value) for name, col, value in fields)) terom@34: terom@34: # found object's id terom@34: id_value = row[id_col] terom@34: terom@34: log.info("Found %s -> %d", dict((name, value) for name, col, value in fields), id_value) terom@34: terom@34: # create new object? terom@34: if not id_value : terom@34: sql = db.insert(table).values(dict((col, value) for name, col, value in fields)) terom@34: terom@34: id_value, = self.app.insert(sql) terom@34: terom@34: log.info("Create %s -> %d", dict((name, value) for name, col, value in fields), id_value) terom@34: terom@34: else : terom@34: # not known terom@34: log.debug("No %s known for order...", id_name) terom@34: terom@34: # return full set of values terom@34: return (id_value, ) + tuple(value for name, col, value in fields) terom@34: terom@34: def process (self, data) : terom@34: """ terom@34: Bind ourselves to the given incoming POST data, and update our order field attributes. terom@34: terom@34: data - the submitted POST data as a MultiDict terom@34: terom@34: Returns True if all fields were processed without errors, False otherwise. terom@34: """ terom@34: terom@48: # bind terom@48: self.data = data terom@48: terom@34: raise NotImplmentedError() terom@34: terom@34: return not self.errors terom@34: terom@34: def render_text_input (self, name, value=None, multiline=False, rows=10, autoscale=True) : terom@34: """ terom@34: Render HTML for a generic text field input. terom@34: terom@34: name - field name, as used for POST terom@34: value - default field value terom@34: multiline - use a multi-line