--- 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),
))