items: Inventory management
authorTero Marttila <terom@fixme.fi>
Mon, 10 Jan 2011 17:04:15 +0200
changeset 48 06fa83c8c0bb
parent 47 d79a560af791
child 49 8bc64ef57ee0
items: Inventory management
svv/controllers.py
svv/database.py
svv/forms.py
svv/items.py
svv/orders.py
svv/urls.py
--- a/svv/controllers.py	Sun Jan 09 17:59:02 2011 +0200
+++ b/svv/controllers.py	Mon Jan 10 17:04:15 2011 +0200
@@ -31,12 +31,22 @@
         self.request = request
         self.urlmap = urlmap
 
-    def url_for (self, endpoint, **values) :
+    def url_for (self, endpoint, fragment=None, **values) :
         """
             Return an URL for the given endpoint, applying the given URL-embedded values
+
+                endpoint            - endpoint mapped in svv.urls to target
+                fragment            - (optional) #fragment to add to URL
+                **values            - url-values for mapped endpoint
         """
 
-        return self.urlmap.build(endpoint, values)
+        url = self.urlmap.build(endpoint, values)
+
+        if fragment :
+            # XXX: encode
+            url += '#' + fragment
+
+        return url
 
     def redirect_for (self, endpoint, **values) :
         """
--- a/svv/database.py	Sun Jan 09 17:59:02 2011 +0200
+++ b/svv/database.py	Mon Jan 10 17:04:15 2011 +0200
@@ -12,7 +12,7 @@
 #
 
 # for ORM definitions; outside of this module
-from sqlalchemy.orm import mapper, sessionmaker, relation
+from sqlalchemy.orm import mapper, sessionmaker, relation, backref
 from sqlalchemy.orm import eagerload, eagerload_all
 
 # used by Application.session
@@ -87,15 +87,44 @@
         Column('updated_at', DateTime, nullable=True, onupdate=func.current_timestamp()),
 )
 
-# editable contract terms for customer order
-# provision for standard terms, or more specific ones for certain customers
-contract_terms = Table('contract_terms', schema,
+# static inventory items
+items = Table('items', schema,
         Column('id', Integer, primary_key=True),
 
-        # short description
+        # item is part of a larger collection
+        Column('parent_id', None, ForeignKey('items.id'), nullable=True),
+
+        # short name
         Column('name', Unicode, nullable=False),
 
-        # full terms in formatted code
-        Column('terms', Unicode, nullable=False),
+        # longer description
+        Column('detail', Unicode, nullable=True),
+
+        # total quantity, or NULL for uncounted items
+        Column('quantity', Integer, nullable=True),
 )
 
+## items included in order
+#order_items = Table('order_items', schema,
+#        # order we are part of
+#        Column('order_id', None, ForeignKey('orders.id'), primary_key=True),
+#        
+#        # which item is selected
+#        Column('item_id', None, ForeignKey('items.id'), primary_key=True),
+#
+#        # how many out of total quantity
+#        Column('quantity', Integer, nullable=True),
+#)
+
+## editable contract terms for customer order
+## provision for standard terms, or more specific ones for certain customers
+#contract_terms = Table('contract_terms', schema,
+#        Column('id', Integer, primary_key=True),
+#
+#        # short description
+#        Column('name', Unicode, nullable=False),
+#
+#        # full terms in formatted code
+#        Column('terms', Unicode, nullable=False),
+#)
+
--- a/svv/forms.py	Sun Jan 09 17:59:02 2011 +0200
+++ b/svv/forms.py	Mon Jan 10 17:04:15 2011 +0200
@@ -39,16 +39,16 @@
         super(FormError, self).__init__(error)
 
 class BaseForm (object) :
-    # any POST data we have processed, updated from process()
+    # processed POST data
     data = None
 
-    def __init__ (self, app) :
+    def __init__ (self) :
         """
-                app             - bind this form to the app state (db etc)
+            Setup state.
+
+            Note that a Form is stateful, and should only be used for one form transaction.
         """
 
-        self.app = app
-
         # accumulated errors
         self.errors = collections.defaultdict(list)
 
@@ -130,12 +130,15 @@
         """
             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 value is None :
+        
+        if not value:
+            # not included in POST, or empty
             return default
 
         try :
@@ -200,7 +203,48 @@
         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) :
         """
@@ -275,6 +319,9 @@
             Returns True if all fields were processed without errors, False otherwise.
         """
 
+        # bind
+        self.data = data
+
         raise NotImplmentedError()
     
         return not self.errors
@@ -377,7 +424,16 @@
                 "$.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) :
         """
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/svv/items.py	Mon Jan 10 17:04:15 2011 +0200
@@ -0,0 +1,526 @@
+# coding: utf-8
+
+from svv import database as db
+from svv.controllers import PageHandler
+from svv.html import tags as html
+from svv.forms import BaseForm, FormError
+
+import logging
+
+log = logging.getLogger('svv.items')
+
+class Item (object) :
+    """
+        Data-mapping for the items table
+    """
+
+db.mapper(Item, db.items, properties=dict(
+    # forward ref to parent
+    parent      = db.relation(Item, remote_side=db.items.c.id, 
+        backref     = db.backref('children', remote_side=db.items.c.parent_id)
+    ),
+))
+
+
+class ItemForm (BaseForm) :
+    """
+        Form for editing item
+    """
+
+    def __init__ (self, session) :
+        """
+            Use given database session for rendering form.
+        """
+
+        super(ItemForm, self).__init__()
+
+        self.session = session
+
+    def defaults (self) :
+        self.item_id = None
+        self.item_name = None
+        self.item_detail = None
+        self.item_quantity = None
+
+        self.item_parent_id = None
+
+    def load (self, item) :
+        """
+            Load info from existing item for editing
+        """
+        
+        self.item_id = item.id
+        self.item_name = item.name
+        self.item_detail = item.detail
+        self.item_quantity = item.quantity
+    
+        self.item_parent_id = item.parent.id if item.parent else None
+
+    def render_parent_select (self, name, parent_id) :
+        """
+            Render <select> for parent
+        """
+
+        # existing items suitable as parents
+        # XXX: currently only top-level items, as a heuristic
+        parent_items = self.session.query(Item.id, Item.name).filter(Item.parent == None).all()
+        
+        return (
+            # <select> with options and selected=
+            self.render_select_input(name, [(0, u"---")] + parent_items, parent_id)
+        )
+
+    def render (self, action, legend=u"Laitejuttu", return_url=None, delete_action=None) :
+        """
+            Render <form> HTML
+
+                return_url          - URL for reset button
+                delete_action       - (optional) URL to delete-item option
+        """
+
+        return (
+            html.form(action=action, method='POST')(
+                html.fieldset(
+                    html.legend(legend),
+
+                    html.ol(
+                        self.render_form_field('item_parent', u"Case", u"Missä laite sijaitsee", (
+                            self.render_parent_select('item_parent', self.item_parent_id)
+                        )),
+
+                        self.render_form_field('item_name', u"Nimi", u"Lyhyt nimi", (
+                            self.render_text_input('item_name', self.item_name)
+                        )),
+
+                        self.render_form_field('item_detail', u"Kuvaus", u"Tarkempi kuvaus", (
+                            self.render_text_input('item_detail', self.item_detail, multiline=True)
+                        )),
+
+                        self.render_form_field('item_quantity', u"Kappalemäärä", u"Jos on esim. useampi piuha/adapter, kirjaa niitten määrä; muuten jätä tyhjäksi", (
+                            self.render_text_input('item_quantity', self.item_quantity)
+                        )),
+                        
+                        html.li(
+                            self.render_submit_button(u"Tallenna"),
+                            self.render_reset_button(u"Palaa inventaariin", return_url) if return_url else None,
+                        ),
+                    ),
+                ),
+            ),
+
+            html.form(action=delete_action, method='POST')(
+                html.fieldset(
+                    html.legend(u"Poistaminen"),
+
+                    html.input(type='hidden', name='items', value=self.item_id),
+
+                    html.ol(
+                        html.li(
+                            html.input(type='submit', name='delete', value=u"Poista"),
+                        )
+                    )
+                ),
+            ) if delete_action else None
+        )
+
+    def process (self, data) :
+        """
+            Process incoming POST data
+        """
+
+        # bind
+        self.data = data
+        
+        self.do_delete = self.process_action_field('delete')
+
+        if not self.do_delete :
+            try :
+                # XXX: self.process_id_field?
+                self.item_parent_id = self.process_integer_field('item_parent')
+
+            except FormError, e :
+                self.fail_field(e, 'item_parent')
+
+            try :
+                self.item_name = self.process_text_field('item_name', required=True)
+
+            except FormError, e :
+                self.fail_field(e, 'item_name')
+
+            try :
+                self.item_detail = self.process_text_field('item_detail')
+
+            except FormError, e :
+                self.fail_field(e, 'item_detail')
+
+            try :
+                self.item_quantity = self.process_integer_field('item_quantity')
+
+            except FormError, e :
+                self.fail_field(e, 'item_quantity')
+
+
+        # ok? 
+        return not self.errors
+
+class DeleteItemForm (BaseForm) :
+    """
+        Display a list of items to delete, and confirm
+    """
+
+    def __init__ (self, session) :
+        """
+            Use given database session for rendering form.
+        """
+
+        super(DeleteItemForm, self).__init__()
+
+        self.session = session
+
+        # list of items to delete
+        self.items = []
+    
+    def render_items_list (self, name, items) :
+        """
+            Render list of items to delete.
+        """
+
+        def render_items (items) :
+            return (
+                html.ul(
+                    render_item(item) for item in items
+                ) if items else None
+            )
+
+
+        def render_item (item) :
+            return html.li(
+                html.input(type='hidden', name='items', value=item.id),
+                item.name,
+                render_items(item.children),
+            )
+
+        return html.div(class_='value')(
+            render_items(items),
+        )
+
+    def render (self, action, return_url) :
+        """
+            Render form with list of target items, and a confirm button
+        """
+
+        return html.form(action=action, method='POST')(
+            html.fieldset(
+                html.legend(u"Poistettavat laitteet"),
+
+                html.ol(
+                    self.render_form_field('items', u"Poistettavat laitteet", u"Kaikki listatut laitteet poistetaan inventaarista", (
+                        self.render_items_list('items', self.items)
+                    )),
+
+                    html.li(
+                        self.render_submit_button(u"Varmista", 'confirm'),
+                        
+                        self.render_reset_button(u"Peruuta", return_url),
+                    ),
+                )
+            )
+        )
+
+    def process_items_list (self, name) :
+        """
+            Uses the incoming list of id's to build the list of Item's to delete.
+        """
+
+        # incoming ids
+        item_ids = self.process_list_field('items', type=int)
+
+        # look up
+        items = self.session.query(Item).filter(Item.id.in_(item_ids)).all()
+
+        # make sure they all exist
+        found = [item_id for item_id in item_ids if item_id in set(item.id for item in items)]
+
+        if set(found) != set(item_ids) :
+            raise FormError(name, found, "Some items were not found")
+
+        if not items :
+            raise FormError(name, [], "No items were given")
+
+        # ok
+        return items
+    
+    def process (self, data) :
+        """
+            Look up list of Item's to delete.
+
+            Returns True if the delete is confirmed, False otherwise.
+        """
+
+        # bind
+        self.data = data
+
+        # load items
+        try :
+            self.items = self.process_items_list('items')
+
+        except FormError, e :
+            self.fail_field(e, 'items')
+
+        # confirm?
+        confirm = self.process_action_field('confirm')
+
+        return not self.errors and confirm
+
+class ItemView (PageHandler) :
+    """
+        Display/edit info for a single item
+    """
+
+    def update (self, item, form) :
+        """
+            Update item data from form
+        """
+
+        # lookup
+        item.parent = self.session.query(Item).get(form.item_parent_id)
+        
+        # modify
+        item.name = form.item_name
+        item.detail = form.item_detail
+        item.quantity = form.item_quantity
+
+        # update
+        self.session.commit()
+
+    def process (self, id) :
+        """
+            Update item data if POST'd
+        """
+        
+        # db
+        self.session = self.app.session()
+
+        # item in concern
+        self.item = self.session.query(Item).get(id)
+
+        # form
+        self.form = ItemForm(self.session)
+        self.form.load(self.item)
+
+        # process?
+        if self.POST :
+            if self.form.process(self.POST) :
+                # update
+                self.update(self.item, self.form)
+
+                # ok, done with item, redirect to full list view...
+                return self.redirect_for(InventoryView, fragment=('item-%d' % self.item.id))
+            
+            else :
+                # re-render form
+                return
+
+    def render_content (self, id) :
+        """
+            View item's info
+        """
+
+        return (
+            html.h1(u"Laite #%d" % self.item.id),
+
+            self.form.render(
+                action          = self.url_for(ItemView, id=id), 
+                return_url      = self.url_for(InventoryView),
+                delete_action   = self.url_for(DeleteItemView),
+            ),
+        )
+
+class NewItemView (PageHandler) :
+    """
+        Create new item
+    """
+
+    def create (self, form) :
+        """
+            Create and return new item from ItemForm data.
+        """
+
+        item = Item()
+        
+        # validate and lookup parent
+        item.parent = self.session.query(Item).get(form.item_parent_id)
+
+        # store
+        item.name = form.item_name
+        item.detail = form.item_detail
+        item.quantity = form.item_quantity
+
+        # insert
+        self.session.add(item)
+        self.session.commit()
+        
+        # ok
+        return item
+
+    def process (self) :
+        # db
+        self.session = self.app.session()
+
+        self.form = ItemForm(self.session)
+        
+        # load POST
+        if self.POST :
+            if self.form.process(self.POST) :
+                # create
+                item = self.create(self.form)
+
+                # redirect to full list view...
+                return self.redirect_for(InventoryView, fragment=('item-%d' % item.id))
+            
+            else :
+                # fail, just render...
+                return
+        
+        else :
+            # render blank form
+            self.form.defaults()
+    
+    def render_content (self) :
+        """
+            Render the proecss()'d form
+        """
+
+        return (
+            self.form.render(action=self.url_for(NewItemView), legend=u"Lisää uusi", delete=True)
+        )
+
+class DeleteItemView (PageHandler) :
+    """
+        Confirm deletion of items.
+    """
+
+    def delete (self, form) :
+        """
+            Delete items from form
+        """
+        
+        # list of root Item's to delete, along with children
+        items = form.items
+        
+        for item in items :
+            self.session.delete(item)
+
+        # ok
+        self.session.commit()
+
+    def process (self) :
+        # db
+        self.session = self.app.session()
+
+        # form
+        self.form = DeleteItemForm(self.session)
+
+        # process
+        if self.form.process(self.POST) :
+            # delete
+            self.delete(self.form)
+                
+            # redirect back
+            return self.redirect_for(InventoryView)
+
+        else :
+            # render
+            pass
+    
+    def render_content (self) :
+        return (
+            self.form.render(action=self.url_for(DeleteItemView), return_url=self.url_for(InventoryView))
+        )
+
+class InventoryView (PageHandler) :
+    """
+        Display overview of all items
+    """
+    
+    def process (self) :
+        # db
+        self.session = self.app.session()
+
+    def render_item_table (self) :
+        """
+            Render HTML for full <table> of all items, sorted heirarchially (by parent)
+        """
+
+        # listing of inventory items
+        items = self.session.query(Item).order_by(Item.parent).all()
+
+        return html.table(
+            html.caption("Kalustolistaus"),
+        
+            html.thead(
+                html.tr(
+                    html.th(title) for title in (
+                        u"#ID",
+                        u"Case",
+                        u"Nimi",
+                        u"Kuvaus",
+                        u"Määrä",
+                    )
+                ),
+            ),
+
+            html.tbody(
+                html.tr(id=('item-%d' % item.id))(
+                    html.td(
+                        html.a(href=self.url_for(ItemView, id=item.id))(
+                            u'#%d' % item.id
+                        )
+                    ),
+
+                    html.td(
+                        html.a(href=self.url_for(ItemView, id=item.parent.id))(
+                            item.parent.name
+                        ) if item.parent else None
+                    ),
+
+                    html.td(
+                        html.a(href=self.url_for(ItemView, id=item.id))(
+                            item.name
+                        )
+                    ),
+
+                    html.td(
+                        item.detail
+                    ),
+
+                    html.td(
+                        "%d kpl" % item.quantity if item.quantity else None
+                    ),
+                ) for item in items
+            )
+        )
+
+    def render_item_form (self) :
+        """
+            Render ItemForm for creating a new item
+        """
+
+        form = ItemForm(self.session)
+        form.defaults()
+        
+        return form.render(action=self.url_for(NewItemView), legend=u"Lisää uusi")
+
+    def render_content (self) :
+        """
+            Full listing of all inventory
+        """
+
+        return (
+            html.h1(u"Inventaari"),
+
+            self.render_item_table(),
+
+            self.render_item_form(),
+        )
+
--- a/svv/orders.py	Sun Jan 09 17:59:02 2011 +0200
+++ b/svv/orders.py	Mon Jan 10 17:04:15 2011 +0200
@@ -114,6 +114,15 @@
         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.
     """
+
+    def __init__ (self, app) :
+        """
+            Bind against app for database access.
+        """
+
+        super(OrderForm, self).__init__()
+
+        self.app = app
     
     def defaults (self) :
         """
@@ -233,7 +242,7 @@
             Returns True if all fields were processed without errors, False otherwise.
         """
 
-        # bind the raw post data
+        # bind
         self.data = data
 
         # customer
@@ -533,9 +542,8 @@
                     ) if not edit_button or self.edit else None,
 
                     tags.li(
-                        tags.input(type='submit', value=u"Tulosta vuokrasopimus"),
-                        tags.input(type='submit', name='edit', value=u"Muokkaa vuokrasopimusta") if edit_button else None,
-
+                        self.render_submit_button(u"Tulosta vuokrasopimus"),
+                        self.render_submit_button(u"Muokkaa vuokrasopimusta", 'edit') if edit_button else None,
                         self.render_reset_button(u"Unohda koko juttu...", return_url) if return_url else None,
                     ),
                 ),
@@ -550,14 +558,14 @@
 
             Returns true if no error input, and not requesting the full editing form ("Edit more" button).
         """
-
-        # bind the raw post data
+        
+        # bind
         self.data = data
 
         self.prefill_placetime = self.process_armed_text_field('prefill_placetime')
         self.prefill_ourname = self.process_armed_text_field('prefill_ourname')
-
-        self.edit = self.process_checkbox_field('edit')
+        
+        self.edit = self.process_action_field('edit')
         
         self.contract_text = self.process_text_field('contract_text', default=self.DEFAULT_TEXT)
         
@@ -770,7 +778,7 @@
             Render the contract panel for our view
         """
 
-        form = OrderContractForm(self.app)
+        form = OrderContractForm()
         form.defaults()
 
         # prefilled values?
@@ -986,7 +994,7 @@
         self.order = self.session.query(Order).options(db.eagerload(Order.customer), db.eagerload(Order.contact)).get(id)
 
         # form
-        self.form = OrderContractForm(self.app)
+        self.form = OrderContractForm()
         self.form.defaults()
         
         # XXX: also use GET data?
--- a/svv/urls.py	Sun Jan 09 17:59:02 2011 +0200
+++ b/svv/urls.py	Mon Jan 10 17:04:15 2011 +0200
@@ -9,6 +9,7 @@
 from svv.customers import CustomersView, CustomerView
 from svv.orders import OrdersView, OrderView, EditOrderView, NewOrderView, OrderContractDocument
 from svv.cal import CalendarView
+from svv.items import ItemView, NewItemView, DeleteItemView, InventoryView
 
 # map URLs -> AppHandler
 URLS = Map((
@@ -23,6 +24,11 @@
     Rule('/customers', endpoint=CustomersView),
     Rule('/customers/<int:id>', endpoint=CustomerView),
 
+    Rule('/inventory', endpoint=InventoryView),
+    Rule('/inventory/new', endpoint=NewItemView),
+    Rule('/inventory/delete', endpoint=DeleteItemView),
+    Rule('/inventory/<int:id>', endpoint=ItemView),
+
     # default view
     Rule('/', endpoint=Index),
 ))