NewOrderView now works ~fully, including error handling
authorTero Marttila <terom@fixme.fi>
Thu, 23 Dec 2010 02:22:34 +0200
changeset 11 90a3c570c227
parent 10 4bdb45071c89
child 12 2d3fb967cd30
NewOrderView now works ~fully, including error handling
static/forms.css
static/layout.css
svv/controllers.py
svv/orders.py
--- a/static/forms.css	Thu Dec 23 01:07:42 2010 +0200
+++ b/static/forms.css	Thu Dec 23 02:22:34 2010 +0200
@@ -4,6 +4,16 @@
  * Inspiration taken from http://articles.sitepoint.com/article/fancy-form-design-css
  */
 
+/* General form errors, and field-specific error lists will display in red */
+form ul.errors,
+form ul.errors a
+{
+    list-style-type: disc;
+    color: #C00;
+
+    margin: 0em 1em 1em;
+}
+
 fieldset
 {
     /* A fieldset will not be completely full-width, and will be vertically separated from adjacent fieldsets*/
@@ -66,6 +76,14 @@
     font-size: small;
 }
 
+/* A <strong> inside the label is an error message */
+fieldset label strong
+{
+    color: #C00;
+
+    text-transform: upppercase;
+}
+
 /* The field element are consistently aligned */
 form input,
 form textarea
@@ -76,6 +94,13 @@
     padding: 4px;
 }
 
+/* A field that failed validation is highlighted */
+form .failed input,
+form .failed textarea
+{
+    border: thin solid red;
+}
+
 form select,
 form input[type=submit]
 {
--- a/static/layout.css	Thu Dec 23 01:07:42 2010 +0200
+++ b/static/layout.css	Thu Dec 23 02:22:34 2010 +0200
@@ -36,6 +36,7 @@
     float: left;
 
     /* Evil? */
+    /* XXX: this seems to break #fragment links */
     padding-bottom: 2000px;
     margin-bottom: -2000px;
             
--- a/svv/controllers.py	Thu Dec 23 01:07:42 2010 +0200
+++ b/svv/controllers.py	Thu Dec 23 02:22:34 2010 +0200
@@ -137,12 +137,8 @@
         )
         
         # perform the actual rendering (run generators etc.)
-        html_text = unicode(html.document(head, layout))
+        return unicode(html.document(head, layout))
         
-        # response object
-        # XXX: unicode?
-        return Response(html_text, mimetype='text/html')
-
     def render (self, **url_values) :
         """
             Render full page HTML
@@ -158,15 +154,23 @@
         return response
 
     def respond (self, url_values) :
-        response = None
+        """
+            Build and return a response from the following steps:
 
-        if self.request.form :
-            # process POST data for e.g. redirect
-            response = self.process(**url_values)
+            * process() 
+            * render() -> render_content() as HTML
+        """
+
+        # process e.g. POST data for e.g. redirect
+        response = self.process(**url_values)
 
         if not response :
             # render page HTML
-            response = self.render(**url_values)
+            html = self.render(**url_values)
+        
+            # response object
+            # XXX: unicode?
+            return Response(html, mimetype='text/html')
 
         # ok
         return response
--- a/svv/orders.py	Thu Dec 23 01:07:42 2010 +0200
+++ b/svv/orders.py	Thu Dec 23 02:22:34 2010 +0200
@@ -9,6 +9,7 @@
 
 import datetime
 import logging
+import collections
 
 log = logging.getLogger('svv.orders')
 
@@ -17,13 +18,16 @@
         A user-level error in a form field
     """
 
-    def __init__ (self, field, error) :
+    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)
 
@@ -43,6 +47,9 @@
 
         self.app = app
 
+        # accumulated errors
+        self.errors = collections.defaultdict(list)
+
     def defaults (self) :
         """
             Update our attributes with default values
@@ -78,11 +85,11 @@
             Returns the value as a str, or default
         """
 
-        if name in self.post :
-            return self.post[name]
+        if name in self.data :
+            return self.data[name]
 
         elif required :
-            raise FormError(name, "Required field")
+            raise FormError(name, None, "Required field")
 
         else :
             return default
@@ -106,7 +113,7 @@
             value = unicode(value)
 
         except UnicodeDecodeError :
-            raise FormError(name, "Failed to decode Unicode characters")
+            raise FormError(name, value, "Failed to decode Unicode characters")
 
         if strip :
             value = value.strip()
@@ -129,7 +136,7 @@
             return int(value)
 
         except ValueError :
-            raise FormError(name, "Must be a number")
+            raise FormError(name, value, "Must be a number")
     
     DATETIME_FORMAT = "%d.%m.%Y %H:%M"
 
@@ -149,7 +156,7 @@
             return datetime.datetime.strptime(value, format)
 
         except ValueError, ex :
-            raise FormError(name, "Invalid date/time value: " + str(ex))
+            raise FormError(name, value, "Invalid date/time value: " + str(ex))
 
     def process_multifield (self, table, id, fields) :
         """
@@ -170,7 +177,7 @@
 
             sql = db.select(columns, (id_col == id_value))
 
-            for row in sql :
+            for row in self.app.query(sql) :
                 # XXX: sanity-check row values vs our values
 
                 # new values
@@ -183,7 +190,7 @@
 
             else :
                 # not found!
-                raise FormError(id_name, "Item selected does not seem to exist")
+                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))
 
@@ -220,47 +227,88 @@
             Process the incoming customer_* fields, returning (customer_id, customer_name).
         """
 
-        return self.process_multifield(db.customers,
-            ('customer_id', db.customers.c.id, self.process_integer_field('customer_id')),
-            (
-                ('customer_name', db.customers.c.name, self.process_string_field('customer_name')),
-            ),
-        )
+        try :
+            customer_id = self.process_integer_field('customer_id')
+            customer_name = self.process_string_field('customer_name')
 
+            if not customer_id and not customer_name :
+                raise FormError('customer_name', None, "Must enter a customer")
+       
+            return self.process_multifield(db.customers,
+                ('customer_id', db.customers.c.id, customer_id),
+                (
+                    ('customer_name', db.customers.c.name, customer_name),
+                ),
+            )
+
+        except FormError, e :
+            # list it
+            self.fail_field(e, 'customer_name')
+
+            return None, None
+ 
     def process_contact (self, customer_id) :
         """
             Process the incoming contact_* fields, returning 
                 (contact_id, contact_name, contact_phone, contact_email, contact_customer)
         """
+        
+        try :
+            contact_id = self.process_integer_field('contact_id')
+            contact_name = self.process_string_field('contact_name')
+            contact_phone = self.process_string_field('contact_phone')
+            contact_email = self.process_string_field('contact_email')
+            contact_customer = customer_id
 
-        return self.process_multifield(db.contacts,
-            ('contact_id', db.contacts.c.id, self.process_integer_field('contact_id')),
-            (
-                ('contact_name', db.contacts.c.name, self.process_string_field('contact_name')),
-                ('contact_phone', db.contacts.c.phone, self.process_string_field('contact_phone')),
-                ('contact_email', db.contacts.c.email, self.process_string_field('contact_email')),
-                ('contact_customer', db.contacts.c.customer, customer_id),
-            ),
-        )
+            if not contact_id and not (contact_name or contact_phone or contact_email) :
+                raise FormError('contact_name', None, "Must enter a contact")
+
+            return self.process_multifield(db.contacts,
+                ('contact_id', db.contacts.c.id, contact_id),
+                (
+                    ('contact_name', db.contacts.c.name, contact_name),
+                    ('contact_phone', db.contacts.c.phone, contact_phone),
+                    ('contact_email', db.contacts.c.email, contact_email),
+                    ('contact_customer', db.contacts.c.customer, contact_customer),
+                ),
+            )
+
+        except FormError, e :
+            # list it
+            self.fail_field(e, 'contact_name' if e.field == 'contact_id' else None)
+
+            return None, None, None, None, None
 
     def process_event (self) :
         """
             Process the incoming event_* fields, returning
                 (event_name, event_description, event_start, event_end)
         """
+        
+        try :
+            event_name = self.process_string_field('event_name')
+            event_description = self.process_string_field('event_description', strip=False)
+            event_start = self.process_datetime_field('event_start')
+            event_end = self.process_datetime_field('event_end')
 
-        event_name = self.process_string_field('event_name')
-        event_description = self.process_string_field('event_description', strip=False)
-        event_start = self.process_datetime_field('event_start')
-        event_end = self.process_datetime_field('event_end')
+            if event_end < event_start :
+                raise FormError('event_start', event_end, "Event must end after start")
 
-        return (event_name, event_description, event_start, event_end)
+            return (event_name, event_description, event_start, event_end)
+
+        except FormError, e :
+            # list it
+            self.fail_field(e)
+
+            return None, None, None, None
 
     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 the raw post data
@@ -270,7 +318,7 @@
         self.customer_id, self.customer_name = self.process_customer()
 
         # contact
-        self.contact_id, self.contact_name, self.contact_phone, self.contact_email, self.contact_customer = self.process_contact(customer_id)
+        self.contact_id, self.contact_name, self.contact_phone, self.contact_email, self.contact_customer = self.process_contact(self.customer_id)
 
         if self.contact_customer and not self.customer_id :
             # TODO: be really smart?
@@ -278,7 +326,22 @@
 
         # event
         self.event_name, self.event_description, self.event_start, self.event_end = self.process_event()
+    
+        return not self.errors
 
+    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_customer_list (self) :
         """
@@ -353,10 +416,10 @@
         """
 
         # all known customers
-        customers = self.build_customer_list()
+        customers = list(self.build_customer_list())
         
         return (
-            self.render_select_input('customer_id', customers, self.customer_id),
+            self.render_select_input('customer_id', [(0, u"Luo uusi")] + customers, self.customer_id),
             self.render_text_input('customer_name', self.customer_name),
 
             tags.script(r"$(document).ready(function () { $('#customer_id').formSelectPreset({textTarget: $('#customer_name')}); });"),
@@ -370,7 +433,7 @@
         contacts = self.build_contact_list(self.customer_id)
 
         return (
-            self.render_select_input('contact_id', ((id, name) for id, name, phone, email in contacts), self.contact_id),
+            self.render_select_input('contact_id', [(0, u"Luo uusi")] + [(id, name) for id, name, phone, email in contacts], self.contact_id),
             self.render_text_input('contact_name', self.contact_name),
 
             tags.script(r"$(document).ready(function () { $('#contact_id').formSelectPreset({textTarget: $('#contact_name')}); });"),
@@ -419,15 +482,21 @@
             Render the label, input control, error note and description for a single field, along with their containing <li>.
         """
 
-        return tags.li(class_='field')(
-            tags.label(title, for_=name),
+        # 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,
 
-            # XXX: somewhere where we tag these!
-            # tags.span("Error!"),
-
             tags.p(description),
+            
+            # possible errors
+            tags.ul(class_='errors')(tags.li(error.message) for error in errors) if errors else None,
         )
 
 
@@ -440,6 +509,18 @@
         """
 
         return tags.form(action=action, method='POST')(
+            (
+                tags.h3(u"Lomakkeessa oli virheitä"),
+                tags.p(u"Korjaa lomake ja lähetä uudelleen"),
+
+                tags.ul(class_='errors')(
+                    tags.li(tags.a(href='#' + error.field)(
+                        tags.em(error.field),
+                        error.message,
+                    )) for field_errors in self.errors.itervalues() for error in field_errors
+                ),
+            ) if self.errors else None,
+
             tags.fieldset(
                 tags.legend(u"Tilaaja"),
                 
@@ -494,38 +575,63 @@
 
 class NewOrderView (PageHandler) :
     """
-            
+        Render form for input, let the user correct their errors, create the order, and redirect out.
     """
 
-    def render_content (self) :
-
-        form = OrderForm(self.app)
-        form.defaults()
+    def create (self, form) :
+        """
+            Create the new order from the given form data, returning the new order's ID
+        """
 
-        return form.render(action=self.url_for(NewOrderView))
-
-        # XXX: under construction..
-
-        if self.POST :
-            print self.POST
-
+        # if we've gotten this far, then we can create it!
+        sql = db.insert(db.orders).values(
+            customer            = form.customer_id,
+            contact             = form.contact_id,
             
-            # if we've gotten this far, then we can create it!
-            sql = db.insert(db.orders).values(
-                customer            = customer_id,
-                contact             = contact_id,
+            event_name          = form.event_name,
+            event_description   = form.event_description,
+            event_start         = form.event_start,
+            event_end           = form.event_end,
+        )
+
+        # go!
+        order_id, = self.app.insert(sql)
+
+        # great
+        return order_id
+
+    def process (self) :
+        """
+            Set up up our form.
+        """
+
+        self.form = OrderForm(self.app)
+
+        # use either POST data or defaults
+        if self.POST :
+            # try and process the input, checking for any failures...
+            if self.form.process(self.POST) :
+                # should be good, create it!
+                order_id = self.create(self.form)
                 
-                event_name          = event_name,
-                event_description   = event_description,
-                event_start         = event_start,
-                event_end           = event_end,
-            )
+                # redirect there now that our business is done and the order exists
+                return self.redirect_for(OrderView, id=order_id)
+            
+            else :
+                # errors in form input
+                pass
 
-            # go!
-            order_id, = self.app.insert(sql)
+        else :
+            # init from defaults
+            self.form.defaults()
+    
+    def render_content (self) :
+        """
+            Render our form
+        """
 
-            # ok, we don't need the /new URL anymore, we can just show the order page
-            return self.redirect_for(OrderView, id=order_id)
+        return (
+            tags.h1(u"Uusi tilaus"),
+            self.form.render(action=self.url_for(NewOrderView))
+        )
 
-
-