svv/forms.py
author Tero Marttila <terom@fixme.fi>
Fri, 21 Jan 2011 04:44:30 +0200
changeset 61 ce1d012d02fe
parent 48 06fa83c8c0bb
permissions -rw-r--r--
html: amend
"""
    Form rendering and handling
"""

from svv.html import tags
from svv import database as db

import collections
import datetime
import logging

try :
    # Python2.6 stdlib
    import json

except ImportError :
    # simplejson, API should be similar
    import simplejson as json


log = logging.getLogger('svv.forms')

class FormError (Exception) :
    """
        A user-level error in a form field
    """

    def __init__ (self, field, value, error) :
        """
                field       - name of field with error
                value       - the errenous value in the form that we recieved it
                              may be None if it was the *lack* of a value that caused the issue
                error       - descriptive text for user
        """

        self.field = field
        self.value = value

        super(FormError, self).__init__(error)

class BaseForm (object) :
    # processed POST data
    data = None

    def __init__ (self) :
        """
            Setup state.

            Note that a Form is stateful, and should only be used for one form transaction.
        """

        # accumulated errors
        self.errors = collections.defaultdict(list)

    def defaults (self) :
        """
            Update our attributes with default values
        """
        
        raise NotImplementedError()

    def fail_field (self, form_error, field=None) :
        """
            Mark the field mentioned inside the given FormError as failed.

                form_error      - the FormError to store
                field           - the name of the field to store the error under, if not the same as in form_error
        """

        field = field or form_error.field

        log.warn("Marking field %s as failed: %s", field, form_error)

        self.errors[field].append(form_error)

    def build_name_for_armed_input (self, name) :
        """
            Return the name used for the checkbox associated with the named armed text input
        """
        
        return name + '_enabled'


    def process_raw_field (self, name, default=None, required=None) :
        """
            Process a generic incoming data field.

                default         - value to return if no value was present
                required        - raise a FormError if no value present

            Returns the value as a str, or default
        """

        if name in self.data :
            return self.data[name]

        elif required :
            raise FormError(name, None, "Required field")

        else :
            return default

    def process_text_field (self, name, default=None, required=None, strip=True) :
        """
            Process a generic incoming string field.

            Trims extra whitespace from around the value, unless strip=False is given.

            Returns the value as unicode, or default.
        """

        value = self.process_raw_field(name, required=required)

        if value is None :
            return default

        try :
            # XXX: decode somehow, or can werkzeug handle that?
            value = unicode(value)

        except UnicodeDecodeError :
            raise FormError(name, value, "Failed to decode Unicode characters")

        if strip :
            value = value.strip()

        return value
    
    def process_integer_field (self, name, default=None, required=None) :
        """
            Process a generic incoming int field.

            If the field is empty, uses the default value.

            Returns the value as int, or default.
        """

        value = self.process_raw_field(name, required=required)
        
        if not value:
            # not included in POST, or empty
            return default

        try :
            return int(value)

        except ValueError :
            raise FormError(name, value, "Must be a number")
    
    DATETIME_FORMAT = "%d.%m.%Y %H:%M"

    def process_datetime_field (self, name, default=None, required=None, format=DATETIME_FORMAT) :
        """
            Process an incoming datetime field.

            Returns the value as datetime, or default.
        """

        value = self.process_raw_field(name, required=required)

        if value is None :
            return default

        try :
            return datetime.datetime.strptime(value, format)

        except ValueError, ex :
            raise FormError(name, value, "Invalid date/time value: " + str(ex))

    def process_checkbox_field (self, name, required=None) :
        """
            Process an incoming checkbox input's value.
            
            Any non-empty/non-whitespace value will be accepted as True.
        """

        value = self.process_raw_field(name, required)

        if not value and required :
            raise FormError(name, value, "Must be checked")

        elif value and value.strip() :
            # checked
            return True

        else :
            # unchecked
            return False
        
    def process_armed_text_field (self, name, required=None) :
        """
            A text field that must be enabled by a checkbox before being used.

            If not enabled, returns False. Otherwise, text field value, and fails if empty but required.
        """

        checkbox_name = self.build_name_for_armed_input(name)
        
        if self.process_checkbox_field(checkbox_name) :
            # use input value
            return self.process_text_field(name, required=required)

        else :
            # not selected
            return False
    
    def process_action_field (self, name, required=None) :
        """
            Given a form with multiple named submit buttons, checks if the given one was used to submit the form.

            If required, raises a FormError if the button wasn't pressed.
        """

        value = self.process_raw_field(name, required)
        
        if value and value.strip() :
            # pressed
            return True

        elif required :
            raise FormError(name, None, "Must be pressed")

        else :
            # some other submit was hit
            return False
    
    def process_list_field (self, name, required=None, type=unicode) :
        """
            Process an incoming multi-value field, returning the full list of values, converted using the given converter.

            Returns an empty list if no incoming values, and not rquired.

            XXX: doesn't strip() the values
        """
        
        if name not in self.data and required :
            # fail
            raise FormError(name, None, "No values given")
        
        elif name not in self.data :
            # empty/default
            return []

        else :
            # type'd list of values
            # uses werkzeug's MultiDict API
            return self.data.getlist(name, type)

    def process_multifield (self, table, id, fields) :
        """
            Process a set of user-given field values for an object with an unique id, and some set of additional fields.
            
            If the id is given, look up the corresponding field values, and return those.

            If any of the fields are given, either look up a matching row, or create a new one, and return its id.

            Returns an (id_name, field_name, ...) N-tuple.
        """

        id_name, id_col, id_value = id

        if id_value :
            # look up object from db
            columns = [col for name, col, value in fields]

            sql = db.select(columns, (id_col == id_value))

            for row in self.app.query(sql) :
                # XXX: sanity-check row values vs our values

                # new values
                fields = tuple(
                    (name, col, row[col]) for name, col, value in fields
                )
                
                # ok, just use the first one
                break

            else :
                # not found!
                raise FormError(id_name, id_value, "Item selected does not seem to exist")
                
            log.info("Lookup %s=%d -> %s", id_name, id_value, dict((name, value) for name, col, value in fields))

        elif any(value for name, col, value in fields) :
            # look up identical object from db?
            sql = db.select([id_col], db.and_(*[(col == value) for name, col, value in fields]))

            for row in self.app.query(sql) :
                if id_value :
                    log.warn("Duplicate %s=%d for %s", id_name, id_value, dict((name, value) for name, col, value in fields))
                
                # found object's id
                id_value = row[id_col]

                log.info("Found %s -> %d", dict((name, value) for name, col, value in fields), id_value)
            
            # create new object?
            if not id_value :
                sql = db.insert(table).values(dict((col, value) for name, col, value in fields))

                id_value, = self.app.insert(sql)

                log.info("Create %s -> %d", dict((name, value) for name, col, value in fields), id_value)

        else :
            # not known
            log.debug("No %s known for order...", id_name)
        
        # return full set of values
        return (id_value, ) + tuple(value for name, col, value in fields)

    def process (self, data) :
        """
            Bind ourselves to the given incoming POST data, and update our order field attributes.

                data        - the submitted POST data as a MultiDict

            Returns True if all fields were processed without errors, False otherwise.
        """

        # bind
        self.data = data

        raise NotImplmentedError()
    
        return not self.errors

    def render_text_input (self, name, value=None, multiline=False, rows=10, autoscale=True) :
        """
            Render HTML for a generic text field input.
                
                name            - field name, as used for POST
                value           - default field value
                multiline       - use a multi-line <textarea> instead
                rows            - number of rows for <textarea> per default
                autoscale       - automatically scale the <textarea> to the number of lines of text
        """

        if multiline :
            if autoscale and value :
                rows = value.count('\n') * 5 / 4

            # XXX: textarea can't be self-closing for some reason?
            return tags.textarea(name=name, id=name, rows=rows, _selfclosing=False, _whitespace_sensitive=True)(value)

        else :
            return tags.input(type='text', name=name, id=name, value=value)

    def render_select_input (self, name, options, value=None) :
        """
            Render HTML for a generic select control.

                name            - field name, as used for POST
                options         - sequence of (value, title) options. `value` may be None to omit.
                value           - the selected value
        """

        return tags.select(name=name, id=name)(
            (
                tags.option(value=opt_value, selected=('selected' if opt_value == value else None))(opt_title)
            ) for opt_value, opt_title in options
        )

    def render_datetime_input (self, name, value=None) :
        """
            Render HTML for a generic datetime control (using jQuery).

                name            - field name, as used for POST
                value           - selected date
        """
            
        return (
            self.render_text_input(name, (value.strftime(self.DATETIME_FORMAT) if value else None)),

            tags.script("$(document).ready(function () { $('#" + name + "').datetimepicker(); });"),
        )

    def render_checkbox_input (self, name, checked=None) :
        """
            Render HTML for a checkbox.
        """

        return tags.input(type="checkbox", name=name, id=name, value="1",
            checked=("checked" if checked else None)
        )

    def render_armed_text_input (self, name, value=False, default=None) :
        """
            Render HTML for a text field that must be enabled by a checkbox before being used.

                value       - the three-state value
                                False       - not checked, use default as value
                                None        - checked, use empty value
                                str         - checked, ues value
        """

        checkbox_name = self.build_name_for_armed_input(name)

        if value is False :
            checked = False
            value = default
        
        else :
            checked = True

        return (
            self.render_checkbox_input(checkbox_name, checked),
            self.render_text_input(name, value),

            tags.script("$(document).ready(function () { $('#" + name + "').formEnabledBy($('#" + checkbox_name + "')); });")
        )

    def render_reset_button (self, value, return_url=None) :
        """
            Render HTML for a <input type="reset"> that abandons the form and returns the user to a page when pressed.

                value                   - button title
                return_url              - (optional) URL to redirect user back to when button is pressed
        """

        return tags.input(type='reset', value=value, 
            onclick = (
                "$.redirect('" + return_url + "')"
            ) if return_url else None,
        )
    
    def render_submit_button (self, value, name=None) :
        """
            Render HTML for a <input type="submit">.

                value                   - button title
                name                    - optionally give the button a name for use with process_action_field
        """

        return tags.input(type='submit', name=name, value=value)

    def render_form_field (self, name, title, description, inputs) :
        """
            Render the label, input control, error note and description for a single field, along with their containing <li>.
        """

        # any errors for this field?
        errors = self.errors[name]

        return tags.li(class_='field' + (' failed' if errors else ''))(
            tags.label((
                title,
                tags.strong(u"(Virheellinen)") if errors else None,
            ), for_=name),

            inputs,

            tags.p(description),
            
            # possible errors
            tags.ul(class_='errors')(tags.li(error.message) for error in errors) if errors else None,
        )

    def render (self, action, submit=u"Tallenna") :
        """
            Render the entire <form>, using any loaded/processed values.

                action          - the target URL for the form to POST to
                submit          - label for the submit button
        """
        
        raise NotImplementedError()