svv/forms.py
changeset 34 260413f89ba9
child 36 d7a159024912
equal deleted inserted replaced
33:471837eb3d96 34:260413f89ba9
       
     1 """
       
     2     Form rendering and handling
       
     3 """
       
     4 
       
     5 from svv.html import tags
       
     6 from svv import database as db
       
     7 
       
     8 import collections
       
     9 import datetime
       
    10 import logging
       
    11 
       
    12 try :
       
    13     # Python2.6 stdlib
       
    14     import json
       
    15 
       
    16 except ImportError :
       
    17     # simplejson, API should be similar
       
    18     import simplejson as json
       
    19 
       
    20 
       
    21 log = logging.getLogger('svv.forms')
       
    22 
       
    23 class FormError (Exception) :
       
    24     """
       
    25         A user-level error in a form field
       
    26     """
       
    27 
       
    28     def __init__ (self, field, value, error) :
       
    29         """
       
    30                 field       - name of field with error
       
    31                 value       - the errenous value in the form that we recieved it
       
    32                               may be None if it was the *lack* of a value that caused the issue
       
    33                 error       - descriptive text for user
       
    34         """
       
    35 
       
    36         self.field = field
       
    37         self.value = value
       
    38 
       
    39         super(FormError, self).__init__(error)
       
    40 
       
    41 class BaseForm (object) :
       
    42     # any POST data we have processed, updated from process()
       
    43     data = None
       
    44 
       
    45     def __init__ (self, app) :
       
    46         """
       
    47                 app             - bind this form to the app state (db etc)
       
    48         """
       
    49 
       
    50         self.app = app
       
    51 
       
    52         # accumulated errors
       
    53         self.errors = collections.defaultdict(list)
       
    54 
       
    55     def defaults (self) :
       
    56         """
       
    57             Update our attributes with default values
       
    58         """
       
    59         
       
    60         raise NotImplementedError()
       
    61 
       
    62     def fail_field (self, form_error, field=None) :
       
    63         """
       
    64             Mark the field mentioned inside the given FormError as failed.
       
    65 
       
    66                 form_error      - the FormError to store
       
    67                 field           - the name of the field to store the error under, if not the same as in form_error
       
    68         """
       
    69 
       
    70         field = field or form_error.field
       
    71 
       
    72         log.warn("Marking field %s as failed: %s", field, form_error)
       
    73 
       
    74         self.errors[field].append(form_error)
       
    75 
       
    76     def build_name_for_armed_input (self, name) :
       
    77         """
       
    78             Return the name used for the checkbox associated with the named armed text input
       
    79         """
       
    80         
       
    81         return name + '_enabled'
       
    82 
       
    83 
       
    84     def process_raw_field (self, name, default=None, required=None) :
       
    85         """
       
    86             Process a generic incoming data field.
       
    87 
       
    88                 default         - value to return if no value was present
       
    89                 required        - raise a FormError if no value present
       
    90 
       
    91             Returns the value as a str, or default
       
    92         """
       
    93 
       
    94         if name in self.data :
       
    95             return self.data[name]
       
    96 
       
    97         elif required :
       
    98             raise FormError(name, None, "Required field")
       
    99 
       
   100         else :
       
   101             return default
       
   102 
       
   103     def process_text_field (self, name, default=None, required=None, strip=True) :
       
   104         """
       
   105             Process a generic incoming string field.
       
   106 
       
   107             Trims extra whitespace from around the value, unless strip=False is given.
       
   108 
       
   109             Returns the value as unicode, or default.
       
   110         """
       
   111 
       
   112         value = self.process_raw_field(name, required=required)
       
   113 
       
   114         if value is None :
       
   115             return default
       
   116 
       
   117         try :
       
   118             # XXX: decode somehow, or can werkzeug handle that?
       
   119             value = unicode(value)
       
   120 
       
   121         except UnicodeDecodeError :
       
   122             raise FormError(name, value, "Failed to decode Unicode characters")
       
   123 
       
   124         if strip :
       
   125             value = value.strip()
       
   126 
       
   127         return value
       
   128     
       
   129     def process_integer_field (self, name, default=None, required=None) :
       
   130         """
       
   131             Process a generic incoming int field.
       
   132 
       
   133             Returns the value as int, or default.
       
   134         """
       
   135 
       
   136         value = self.process_raw_field(name, required=required)
       
   137 
       
   138         if value is None :
       
   139             return default
       
   140 
       
   141         try :
       
   142             return int(value)
       
   143 
       
   144         except ValueError :
       
   145             raise FormError(name, value, "Must be a number")
       
   146     
       
   147     DATETIME_FORMAT = "%d.%m.%Y %H:%M"
       
   148 
       
   149     def process_datetime_field (self, name, default=None, required=None, format=DATETIME_FORMAT) :
       
   150         """
       
   151             Process an incoming datetime field.
       
   152 
       
   153             Returns the value as datetime, or default.
       
   154         """
       
   155 
       
   156         value = self.process_raw_field(name, required=required)
       
   157 
       
   158         if value is None :
       
   159             return default
       
   160 
       
   161         try :
       
   162             return datetime.datetime.strptime(value, format)
       
   163 
       
   164         except ValueError, ex :
       
   165             raise FormError(name, value, "Invalid date/time value: " + str(ex))
       
   166 
       
   167     def process_checkbox_field (self, name, required=None) :
       
   168         """
       
   169             Process an incoming checkbox input's value.
       
   170             
       
   171             Any non-empty/non-whitespace value will be accepted as True.
       
   172         """
       
   173 
       
   174         value = self.process_raw_field(name, required)
       
   175 
       
   176         if not value and required :
       
   177             raise FormError(name, value, "Must be checked")
       
   178 
       
   179         elif value and value.strip() :
       
   180             # checked
       
   181             return True
       
   182 
       
   183         else :
       
   184             # unchecked
       
   185             return False
       
   186         
       
   187     def process_armed_text_field (self, name, required=None) :
       
   188         """
       
   189             A text field that must be enabled by a checkbox before being used.
       
   190 
       
   191             If not enabled, returns False. Otherwise, text field value, and fails if empty but required.
       
   192         """
       
   193 
       
   194         checkbox_name = self.build_name_for_armed_input(name)
       
   195         
       
   196         if self.process_checkbox_field(checkbox_name) :
       
   197             # use input value
       
   198             return self.process_text_field(name, required=required)
       
   199 
       
   200         else :
       
   201             # not selected
       
   202             return False
       
   203 
       
   204 
       
   205     def process_multifield (self, table, id, fields) :
       
   206         """
       
   207             Process a set of user-given field values for an object with an unique id, and some set of additional fields.
       
   208             
       
   209             If the id is given, look up the corresponding field values, and return those.
       
   210 
       
   211             If any of the fields are given, either look up a matching row, or create a new one, and return its id.
       
   212 
       
   213             Returns an (id_name, field_name, ...) N-tuple.
       
   214         """
       
   215 
       
   216         id_name, id_col, id_value = id
       
   217 
       
   218         if id_value :
       
   219             # look up object from db
       
   220             columns = [col for name, col, value in fields]
       
   221 
       
   222             sql = db.select(columns, (id_col == id_value))
       
   223 
       
   224             for row in self.app.query(sql) :
       
   225                 # XXX: sanity-check row values vs our values
       
   226 
       
   227                 # new values
       
   228                 fields = tuple(
       
   229                     (name, col, row[col]) for name, col, value in fields
       
   230                 )
       
   231                 
       
   232                 # ok, just use the first one
       
   233                 break
       
   234 
       
   235             else :
       
   236                 # not found!
       
   237                 raise FormError(id_name, id_value, "Item selected does not seem to exist")
       
   238                 
       
   239             log.info("Lookup %s=%d -> %s", id_name, id_value, dict((name, value) for name, col, value in fields))
       
   240 
       
   241         elif any(value for name, col, value in fields) :
       
   242             # look up identical object from db?
       
   243             sql = db.select([id_col], db.and_(*[(col == value) for name, col, value in fields]))
       
   244 
       
   245             for row in self.app.query(sql) :
       
   246                 if id_value :
       
   247                     log.warn("Duplicate %s=%d for %s", id_name, id_value, dict((name, value) for name, col, value in fields))
       
   248                 
       
   249                 # found object's id
       
   250                 id_value = row[id_col]
       
   251 
       
   252                 log.info("Found %s -> %d", dict((name, value) for name, col, value in fields), id_value)
       
   253             
       
   254             # create new object?
       
   255             if not id_value :
       
   256                 sql = db.insert(table).values(dict((col, value) for name, col, value in fields))
       
   257 
       
   258                 id_value, = self.app.insert(sql)
       
   259 
       
   260                 log.info("Create %s -> %d", dict((name, value) for name, col, value in fields), id_value)
       
   261 
       
   262         else :
       
   263             # not known
       
   264             log.debug("No %s known for order...", id_name)
       
   265         
       
   266         # return full set of values
       
   267         return (id_value, ) + tuple(value for name, col, value in fields)
       
   268 
       
   269     def process (self, data) :
       
   270         """
       
   271             Bind ourselves to the given incoming POST data, and update our order field attributes.
       
   272 
       
   273                 data        - the submitted POST data as a MultiDict
       
   274 
       
   275             Returns True if all fields were processed without errors, False otherwise.
       
   276         """
       
   277 
       
   278         raise NotImplmentedError()
       
   279     
       
   280         return not self.errors
       
   281 
       
   282     def render_text_input (self, name, value=None, multiline=False, rows=10, autoscale=True) :
       
   283         """
       
   284             Render HTML for a generic text field input.
       
   285                 
       
   286                 name            - field name, as used for POST
       
   287                 value           - default field value
       
   288                 multiline       - use a multi-line <textarea> instead
       
   289                 rows            - number of rows for <textarea> per default
       
   290                 autoscale       - automatically scale the <textarea> to the number of lines of text
       
   291         """
       
   292 
       
   293         if multiline :
       
   294             if autoscale and value :
       
   295                 rows = value.count('\n') * 5 / 4
       
   296 
       
   297             # XXX: textarea can't be self-closing for some reason?
       
   298             return tags.textarea(name=name, id=name, rows=rows, _selfclosing=False, _whitespace_sensitive=True)(value)
       
   299 
       
   300         else :
       
   301             return tags.input(type='text', name=name, id=name, value=value)
       
   302 
       
   303     def render_select_input (self, name, options, value=None) :
       
   304         """
       
   305             Render HTML for a generic select control.
       
   306 
       
   307                 name            - field name, as used for POST
       
   308                 options         - sequence of (value, title) options. `value` may be None to omit.
       
   309                 value           - the selected value
       
   310         """
       
   311 
       
   312         return tags.select(name=name, id=name)(
       
   313             (
       
   314                 tags.option(value=opt_value, selected=('selected' if opt_value == value else None))(opt_title)
       
   315             ) for opt_value, opt_title in options
       
   316         )
       
   317 
       
   318     def render_datetime_input (self, name, value=None) :
       
   319         """
       
   320             Render HTML for a generic datetime control (using jQuery).
       
   321 
       
   322                 name            - field name, as used for POST
       
   323                 value           - selected date
       
   324         """
       
   325             
       
   326         return (
       
   327             self.render_text_input(name, (value.strftime(self.DATETIME_FORMAT) if value else None)),
       
   328 
       
   329             tags.script("$(document).ready(function () { $('#" + name + "').datetimepicker(); });"),
       
   330         )
       
   331 
       
   332     def render_checkbox_input (self, name, checked=None) :
       
   333         """
       
   334             Render HTML for a checkbox.
       
   335         """
       
   336 
       
   337         return tags.input(type="checkbox", name=name, id=name, value="1",
       
   338             checked=("checked" if checked else None)
       
   339         )
       
   340 
       
   341     def render_armed_text_input (self, name, value=False, default=None) :
       
   342         """
       
   343             Render HTML for a text field that must be enabled by a checkbox before being used.
       
   344 
       
   345                 value       - the three-state value
       
   346                                 False       - not checked, use default as value
       
   347                                 None        - checked, use empty value
       
   348                                 str         - checked, ues value
       
   349         """
       
   350 
       
   351         checkbox_name = self.build_name_for_armed_input(name)
       
   352 
       
   353         if value is False :
       
   354             checked = False
       
   355             value = default
       
   356         
       
   357         else :
       
   358             checked = True
       
   359 
       
   360         return (
       
   361             self.render_checkbox_input(checkbox_name, checked),
       
   362             self.render_text_input(name, value),
       
   363 
       
   364             tags.script("$(document).ready(function () { $('#" + name + "').formEnabledBy($('#" + checkbox_name + "')); });")
       
   365         )
       
   366 
       
   367 
       
   368     def render_form_field (self, name, title, description, inputs) :
       
   369         """
       
   370             Render the label, input control, error note and description for a single field, along with their containing <li>.
       
   371         """
       
   372 
       
   373         # any errors for this field?
       
   374         errors = self.errors[name]
       
   375 
       
   376         return tags.li(class_='field' + (' failed' if errors else ''))(
       
   377             tags.label((
       
   378                 title,
       
   379                 tags.strong(u"(Virheellinen)") if errors else None,
       
   380             ), for_=name),
       
   381 
       
   382             inputs,
       
   383 
       
   384             tags.p(description),
       
   385             
       
   386             # possible errors
       
   387             tags.ul(class_='errors')(tags.li(error.message) for error in errors) if errors else None,
       
   388         )
       
   389 
       
   390     def render (self, action, submit=u"Tallenna") :
       
   391         """
       
   392             Render the entire <form>, using any loaded/processed values.
       
   393 
       
   394                 action          - the target URL for the form to POST to
       
   395                 submit          - label for the submit button
       
   396         """
       
   397         
       
   398         raise NotImplementedError()
       
   399 
       
   400 
       
   401