Implement working NewOrderView, and start restructuring it again right away...
authorTero Marttila <terom@fixme.fi>
Thu, 23 Dec 2010 00:57:46 +0200
changeset 9 0327b83959e9
parent 8 27e37082625e
child 10 4bdb45071c89
Implement working NewOrderView, and start restructuring it again right away...
static/js/forms.js
svv/application.py
svv/customers.py
svv/database.py
svv/orders.py
--- a/static/js/forms.js	Wed Dec 22 21:30:27 2010 +0200
+++ b/static/js/forms.js	Thu Dec 23 00:57:46 2010 +0200
@@ -4,6 +4,29 @@
 
 (function($) {
     /*
+     * Form field is empty - i.e. null value
+     *
+     * Pure whitespace also counts
+     */
+    $.fn.empty = function () {
+        return !this.val() || $.trim(this.val()) == "";
+    }
+
+    /**
+     * Query or set form field disabled state
+     */
+    $.fn.disabled = function (flag) {
+        if (flag == undefined)
+            // XXX: jQuery returns `true` here?
+            return !!this.attr("disabled");
+
+        if (flag)
+            this.attr("disabled", "disabled");
+        else
+            this.removeAttr("disabled");
+    }
+
+    /*
      * When non-zero <select>/<option> is selected, apply that option as pre-filled values for other form items
      */
     $.fn.formSelectPreset = function (opts) {
@@ -18,7 +41,7 @@
             textTarget: null,
         }, opts);
 
-        this.change(function (event) {
+        function update () {
             // selected option value (i.e. id)
             value = $(this).val();
 
@@ -33,13 +56,13 @@
 
             if (value == opts.valueDefault) {
                 // clear and re-enable fields
-                if (opts.valueTarget) {
-                    opts.valueTarget.removeAttr('disabled');
+                if (opts.valueTarget && (opts.valueTarget.disabled() || opts.valueTarget.empty())) {
+                    opts.valueTarget.disabled(false);
                     opts.valueTarget.val("");
                 }
 
-                if (opts.textTarget) {
-                    opts.textTarget.removeAttr('disabled');
+                if (opts.textTarget && (opts.textTarget.disabled() || opts.textTarget.empty())) {
+                    opts.textTarget.disabled(false);
                     opts.textTarget.val("");
                 }
 
@@ -48,14 +71,18 @@
 
             // set field values
             if (opts.valueTarget) {
-                opts.valueTarget.attr('disabled', "disabled");
+                opts.valueTarget.disabled(true);
                 opts.valueTarget.val(value);
             }
 
             if (opts.textTarget) {
-                opts.textTarget.attr('disabled', "disabled");
+                opts.textTarget.disabled(true);
                 opts.textTarget.val(text);
             }
-        });
+        }
+        
+        // update linked field state on update, and startup..
+        this.change(update);
+        this.change()
     }
 })(jQuery);
--- a/svv/application.py	Wed Dec 22 21:30:27 2010 +0200
+++ b/svv/application.py	Thu Dec 23 00:57:46 2010 +0200
@@ -64,4 +64,15 @@
         """
 
         return self.engine.execute(sql)
+    
+    def insert (self, sql, **values) :
+        """
+            Execute a simple SQL insert, returning the new IDs a a tuple.
 
+            For the common case of a table with a simplex primary key, a call looks like:
+
+                new_id, = app.insert(sql, foo=bar)
+        """
+        
+        return self.engine.execute(sql, **values).last_inserted_ids()
+
--- a/svv/customers.py	Wed Dec 22 21:30:27 2010 +0200
+++ b/svv/customers.py	Thu Dec 23 00:57:46 2010 +0200
@@ -35,8 +35,8 @@
                     tags.th("Nimi"),
                 ),
                 (tags.tr(
-                    tags.td(tags.a(href=self.build_url(CustomerView, id=customer[db.customers.c.id]))('#%d' % customer[db.customers.c.id])),
-                    tags.td(tags.a(href=self.build_url(CustomerView, id=customer[db.customers.c.id]))(customer[db.customers.c.name])),
+                    tags.td(tags.a(href=self.url_for(CustomerView, id=customer[db.customers.c.id]))('#%d' % customer[db.customers.c.id])),
+                    tags.td(tags.a(href=self.url_for(CustomerView, id=customer[db.customers.c.id]))(customer[db.customers.c.name])),
                 ) for customer in customers)
             ),
 
@@ -51,7 +51,7 @@
                 (tags.tr(
                     tags.td(tags.a(href='#')('#%d' % row[db.contacts.c.id])),
                     tags.td(
-                        tags.a(href=self.build_url(CustomerView, id=row[db.customers.c.id]))(row[db.customers.c.name])
+                        tags.a(href=self.url_for(CustomerView, id=row[db.customers.c.id]))(row[db.customers.c.name])
                             if row[db.customers.c.id] else " "
                     ),
                     tags.td(
--- a/svv/database.py	Wed Dec 22 21:30:27 2010 +0200
+++ b/svv/database.py	Thu Dec 23 00:57:46 2010 +0200
@@ -67,8 +67,8 @@
         Column('event_description', Unicode, nullable=True),
         
         # rough duration of event; time interval during which items are reserved
-        Column('start', DateTime),
-        Column('end', DateTime),
+        Column('event_start', DateTime),
+        Column('event_end', DateTime),
         
         # raw CREATE/UPDATE timestamp
         Column('created_at', DateTime, nullable=False, default=func.now()),
--- a/svv/orders.py	Wed Dec 22 21:30:27 2010 +0200
+++ b/svv/orders.py	Thu Dec 23 00:57:46 2010 +0200
@@ -8,67 +8,342 @@
 from svv import database as db
 
 import datetime
-
-class OrdersView (PageHandler) :
-    def render (self) :
-        return tags.h1("Orders list")
-
-class OrderView (PageHandler) :
-    def render (self) :
-        return tags.h1("Order info")
+import logging
 
-class NewOrderView (PageHandler) :
-    def render_form_field (self, title, description, name, input) :
-        return tags.li(class_='field')(
-            tags.label(title, for_=name),
+log = logging.getLogger('svv.orders')
 
-            input,
+class FormError (Exception) :
+    """
+        A user-level error in a form field
+    """
 
-            # tags.span("Error!"),
+    def __init__ (self, field, error) :
+        """
+                field       - name of field with error
+                error       - descriptive text for user
+        """
 
-            tags.p(description),
+        self.field = field
+
+        super(FormError, self).__init__(error)
+
+class OrderForm (object) :
+    """
+        A single instance of a <form>, where we can process submitted data from the client, storing the associated
+        Order-related data, and then render a form for any of that related data.
+    """
+    
+    # any POST data we have processed, updated from process()
+    data = None
+
+    def __init__ (self, app) :
+        """
+                app             - bind this form to the app state (db etc)
+        """
+
+        self.app = app
+
+    def defaults (self) :
+        """
+            Update our attributes with default values
+        """
+
+        self.customer_id = None
+        self.customer_name = None
+
+        self.contact_id = None
+        self.contact_name = None
+        self.contact_phone = None
+        self.contact_email = None
+        self.contact_customer = None
+
+        self.event_name = None
+        self.event_description = None
+
+        tomorrow = datetime.date.today() + datetime.timedelta(days=1)
+        
+        # default to tomorrow afternoon
+        self.event_start = datetime.datetime.combine(tomorrow, datetime.time(16, 00))
+        
+        # automatically determined once start is set
+        self.event_end = None
+
+    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.post :
+            return self.post[name]
+
+        elif required :
+            raise FormError(name, "Required field")
+
+        else :
+            return default
+
+    def process_string_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, "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.
+
+            Returns the value as int, or default.
+        """
+
+        value = self.process_raw_field(name, required=required)
+
+        if value is None :
+            return default
+
+        try :
+            return int(value)
+
+        except ValueError :
+            raise FormError(name, "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, "Invalid date/time value: " + str(ex))
+
+    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 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, "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_customer (self) :
+        """
+            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')),
+            ),
         )
 
-    def get_customer_list (self) :
+    def process_contact (self, customer_id) :
         """
-            Get (id, name) list of customers
+            Process the incoming contact_* fields, returning 
+                (contact_id, contact_name, contact_phone, contact_email, contact_customer)
         """
 
-        return self.app.query(db.select([db.customers.c.id, db.customers.c.name]))
+        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),
+            ),
+        )
 
-    def get_contact_list (self, customer_id=None) :
+    def process_event (self) :
         """
-            Get (id, name, phone, email) list of contacts, optionally for given customer if given.
+            Process the incoming event_* fields, returning
+                (event_name, event_description, event_start, event_end)
         """
 
-        query = db.select([db.contacts.c.id, db.contacts.c.name, db.contacts.c.phone, db.contacts.c.email])
+        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')
+
+        return (event_name, event_description, event_start, event_end)
+
+    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
+        """
+
+        # bind the raw post data
+        self.data = data
+
+        # customer
+        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)
+
+        if self.contact_customer and not self.customer_id :
+            # TODO: be really smart?
+            pass
+
+        # event
+        self.event_name, self.event_description, self.event_start, self.event_end = self.process_event()
+
+
+    def build_customer_list (self) :
+        """
+            Query a (id, name) list of customers.
+        """
+
+        sql = db.select([db.customers.c.id, db.customers.c.name])
+
+        return self.app.query(sql)
+
+    def build_contact_list (self, customer_id=None) :
+        """
+            Query a (id, name, phone, email) list of contacts, optionally for given customer if given.
+        """
+
+        sql = db.select([db.contacts.c.id, db.contacts.c.name, db.contacts.c.phone, db.contacts.c.email])
 
         if customer_id :
-            query = query.where((db.contacts.c.customer == customer_id))
+            sql = sql.where((db.contacts.c.customer == customer_id))
 
-        return self.app.query(query)
+        return self.app.query(sql)
+
+
+    def render_text_input (self, name, value=None, multiline=False) :
+        """
+            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
+        """
+
+        if multiline :
+            # XXX: textarea can't be self-closing for some reason?
+            return tags.textarea(name=name, id=name, _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_customer_input (self) :
         """
-            Render HTML for customer field <input>s
+            Render HTML for customer_id/name field inputs.
         """
 
-        # pre-selected values?
-        customer_id = self.POST.get('customer_id')
-        customer_name = self.POST.get('customer_name')
-
-        # available values
-        customers = self.get_customer_list()
+        # all known customers
+        customers = self.build_customer_list()
         
         return (
-            tags.select(name='customer_id', id='customer_id')(
-                tags.option(value=0)(u"Luo uusi"),
-
-                [(
-                    tags.option(value=id, selected=('selected' if id == customer_id or name == customer_name else None))(name)
-                ) for id, name in customers],
-            ),
-            tags.input(type='text', name='customer_name', id='customer_name'),
+            self.render_select_input('customer_id', 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')}); });"),
         )
@@ -77,67 +352,25 @@
         """
             Render HTML for contact name field <input>s
         """
-
-        # pre-selected values
-        customer_id = self.POST.get('customer_id')
-        contact_id = self.POST.get('contact_id')
-        contact_name = self.POST.get('contact_name')
-        contact_phone = self.POST.get('contact_phone')
-        contact_email = self.POST.get('contact_email')
-
-        # available values
-        contacts = self.get_contact_list()
+        # recommended contacts for selected customer, if known
+        contacts = self.get_contact_list(self.customer_id)
 
         return (
-            tags.select(name='contact_id', id='contact_id')(
-                tags.option(value=0)(u"Luo uusi"),
-
-                [(
-                    tags.option(value=id, selected=('selected' if id == contact_id else None))(name)
-                ) for id, name, phone, email in contacts],
-            ),
-
-            tags.input(type='text', name='contact_name', id='contact_name'),
+            self.render_select_input('contact_id', ((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')}); });"),
         )
 
-    DATETIME_FORMAT = "%d.%m.%Y %H:%M"
-
-    def get_POST_datetime (self, name, default=None) :
-        """
-            Return a datetime for something the client POST'd
-        """
-
-        value = self.POST.get(name)
-
-        if value :
-            # XXX: handle invalid format..
-            return datetime.datetime.strptime(value, self.DATETIME_FORMAT)
-
-        else :
-            return default
-
     def render_event_input (self) :
         """
             Render HTML for event start/end field <input>s
         """
         
-        # XXX: sensible defaults?
-        tomorrow = datetime.date.today() + datetime.timedelta(days=1)
-        default_start = datetime.datetime.combine(tomorrow, datetime.time(16, 00))
-        
-        # automatically determined once start is set
-        default_end = None
-
-        # pre-selected values
-        event_start = self.get_POST_datetime('event_start', default_start)
-        event_end = self.get_POST_datetime('event_end', default_end)
-
         return (
-            tags.input(type='text', name='event_start', id='event_start', value=(event_start.strftime(self.DATETIME_FORMAT) if event_start else None)),
+            self.render_text_input('event_start', (self.event_start.strftime(self.DATETIME_FORMAT) if event_start else None)),
             " - ",
-            tags.input(type='text', name='event_end', id='event_end', value=(event_end.strftime(self.DATETIME_FORMAT) if event_end else None)),
+            self.render_text_input('event_end', (self.event_end.strftime(self.DATETIME_FORMAT) if event_end else None)),
 
             tags.script(r"""
 $(document).ready(function () { 
@@ -147,6 +380,15 @@
     event_start.datetimepicker();
     event_end.datetimepicker(); 
     
+/* Buggy shit doesn't work
+
+    {
+        beforeShow: function (input, inst) {
+            // copy default value from event_start
+            event_end.datetimepicker("option", "defaultDate", event_start.datetimepicker("getDate"));
+        }
+    }   
+
     event_start.change(function () {
         // copy value as default
         var start_date = event_start.datetimepicker("getDate");
@@ -156,28 +398,55 @@
 
     // init default as well
     event_start.change();
+*/
 });"""      ),
 
         )
 
-    def render (self) :
-        return tags.form(action=self.build_url(NewOrderView), method='POST')(
-            tags.h1(u"Uusi tilaus"),
+    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>.
+        """
 
+        return tags.li(class_='field')(
+            tags.label(title, for_=name),
+
+            inputs,
+
+            # XXX: somewhere where we tag these!
+            # tags.span("Error!"),
+
+            tags.p(description),
+        )
+
+
+    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
+        """
+
+        return tags.form(action=action, method='POST')(
             tags.fieldset(
                 tags.legend(u"Tilaaja"),
                 
                 tags.ol(
-                    self.render_form_field(u"Tilaaja", u"Tilaavan yhdistyksen/henkilön nimi", 'customer_name', self.render_customer_input()),
-
-                    self.render_form_field(u"Yhteyshenkilö", u"Yhteyshenkilön nimi, jos eri kun tilaaja", 'contact_name', self.render_contact_input()),
-
-                    self.render_form_field(u"Puhelin", u"Yhteyshenkilön puhelinnumero", 'contact_phone', (
-                        tags.input(type='text', name='contact_phone')
+                    self.render_form_field('customer_name', u"Tilaaja", u"Tilaavan yhdistyksen/henkilön nimi", (
+                        self.render_customer_input()
                     )),
 
-                    self.render_form_field(u"Sähköposti", u"Yhteyshenkilön sähköpostiosoite", 'contact_email', (
-                        tags.input(type='text')
+                    self.render_form_field('contact_name', u"Yhteyshenkilö", u"Yhteyshenkilön nimi, jos eri kun tilaaja", (
+                        self.render_contact_input()
+                    )),
+
+                    self.render_form_field('contact_phone', u"Puhelin", u"Yhteyshenkilön puhelinnumero", (
+                        self.render_text_input('contact_phone', self.contact_phone)
+                    )),
+
+                    self.render_form_field('contact_email', u"Sähköposti", u"Yhteyshenkilön sähköpostiosoite", (
+                        self.render_text_input('contact_email', self.contact_email)
                     )),
                 ),
             ),
@@ -186,18 +455,61 @@
                 tags.legend(u"Tapahtuma"),
            
                 tags.ol(
-                    self.render_form_field(u"Tapahtuma", u"Tapahtuman lyhyt nimi", 'event_name', (
-                        tags.input(type='text', name='event_name')
+                    self.render_form_field('event_name', u"Tapahtuma", u"Tapahtuman lyhyt nimi", (
+                        self.render_text_input('event_name', self.event_name)
                     )),
 
-                    self.render_form_field(u"Lisätiedot", u"Tapahtuman tarkemmat tiedot", 'event_description', (
-                        tags.textarea("", rows=8, name='event_description')
+                    self.render_form_field('event_description', u"Lisätiedot", u"Tapahtuman tarkemmat tiedot", (
+                        self.render_text_input('event_description', self.event_description, multiline=True)
                     )),
 
-                    self.render_form_field(u"Ajankohta", u"Tapahtuman ajankohta (kamat noudetaan - palautetaan)", 'event_start', self.render_event_input()),
+                    self.render_form_field('event_start', u"Ajankohta", u"Tapahtuman ajankohta (kamat noudetaan - palautetaan)", (
+                        self.render_event_input()
+                    )),
                 ),
             ),
 
-            tags.input(type='submit', value="Tallenna"),
+            tags.input(type='submit', value=submit),
         )
 
+
+class OrdersView (PageHandler) :
+    def render (self) :
+        return tags.h1("Orders list")
+
+class OrderView (PageHandler) :
+    def render (self, id) :
+        return tags.h1("Order info for #%d" % (id, ))
+
+class NewOrderView (PageHandler) :
+    """
+        
+    """
+
+    def render (self) :
+        if self.POST :
+            print self.POST
+
+            # 
+            
+            # 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          = event_name,
+                event_description   = event_description,
+                event_start         = event_start,
+                event_end           = event_end,
+            )
+
+            # go!
+            order_id, = self.app.insert(sql)
+
+            # ok, we don't need the /new URL anymore, we can just show the order page
+            return self.redirect_for(OrderView, id=order_id)
+
+        # render form
+        return self.render_form()
+
+