from pvl.web import html
from pvl.verkko import web, db
import math
import logging; log = logging.getLogger('pvl.verkko.table')
class Column (object) :
"""
web.Table column spec, representing a property of an SQLAlchemy ORM model class.
"""
def __init__ (self, attr, title, column, rowhtml=None, sort=True, filter=True, colcss=True, rowfilter=False, rowtitle=None, rowcss=None) :
"""
attr - name of column value, used for http query arg, html css class, model getattr()
title - title of column
column - the model column property, used to build queries
rowhtml - function returning column value as HTML for model
sort - allow sorting by column
filter - allow filtering by column
colcss - apply css class for column; True -> attr
rowfilter - allow filtering by row value
rowtitle - function returning column title for model
rowcss - function returning column class for model
"""
if colcss is True :
colcss = attr
self.attr = attr
self.title = title
self.column = column
# column attrs
self.sort = sort
self.filter = filter
self.colcss = colcss
# row attrs
self.rowhtml = rowhtml
self.rowfilter = rowfilter
self.rowtitle = rowtitle
self.rowcss = rowcss
def render_header (self, sorturl) :
"""
Render <th> for <thead> in given Table.
"""
header = self.title
if self.sort :
header = html.a(href=sorturl)(header)
return html.th(header)
def render_filter_input (self, filters) :
"""
Render filter <input> in given Table.
"""
value = filters.get(self.attr)
if value :
# XXX: multi-valued filters?
value = value[0]
else :
value = None
return html.input(type='text', name=self.attr, value=value)
def render_header_filter (self, filters) :
"""
Render <td><input> for <thead> in given Table.
"""
if self.filter :
input = self.render_filter_input(filters)
else :
input = None
return html.td(class_=self.colcss)(input)
def cell_value (self, item) :
"""
Return value for cell.
"""
# XXX: this is sometimes broken, figure out how to index by column
return getattr(item, self.attr)
def cell_html (self, item, value) :
"""
Render contents for <td>.
"""
if self.rowhtml :
return self.rowhtml(item)
else :
return value
def cell_css (self, item, value=None, hilight=None) :
"""
Return CSS classes for <td>.
"""
if self.colcss :
yield self.colcss
if self.rowcss :
yield self.rowcss(item)
if hilight :
# lookup attr/value
hilight = self.attr in hilight and value in hilight[self.attr]
if hilight :
yield 'hilight'
def cell_title (self, item, value=None) :
"""
Return title= for <td>
"""
if self.rowtitle :
return self.rowtitle(item)
def render_cell (self, item, table, filters=None, hilight=None) :
"""
Render <td> for item in <tbody> in given Table.
hilight - optionally higlight given { attr: value }'s using CSS.
"""
value = self.cell_value(item)
if self.rowfilter and filters is not None :
# filter-link by row-value
filters = { self.attr: value }
else :
filters = None
yield table.render_cell(self.cell_html(item, value),
css = tuple(self.cell_css(item, value, hilight=hilight)),
filters = filters,
title = self.cell_title(item),
)
class Table (object) :
"""
Render <table> with Columns from SQLAlchemy ORM model class.
"""
COLUMNS = ()
ITEMS = None
# attr -> column
ATTRS = dict((col.attr, col) for col in COLUMNS)
# items per page
PAGE = 10
def __init__ (self, url, columns=None, table_url=None, item_url=None, caption=None, page=None) :
"""
url - pvl.web.Handler.url()
table_url - ListHandler, or self?
item_url - ItemHandler, or self#id
columns - sequence of Columns
caption - optional <caption>
page - items per page
"""
self.url = url
self.columns = columns or self.COLUMNS
self.table_url = table_url
self.item_url = item_url
self.caption = caption
self.page = page or self.PAGE
def tableurl (self, filters=None, **opts) :
"""
URL for table with given opts, keeping our sorting/filtering unless overriden.
"""
args = dict()
# apply
if filters :
args.update(filters)
if opts :
args.update(opts)
return self.url(self.table_url, **args)
def sorturl (self, attr, sort=None, **opts) :
"""
URL for table sorted by given column, reversing direction if already sorting by given column.
"""
if not sort :
sort = attr
elif sort.lstrip('+-') != attr :
sort = attr
elif sort.startswith('-') :
sort = "+" + attr
else :
sort = "-" + attr
return self.tableurl(sort=sort, **opts)
def itemurl (self, item) :
"""
URL for given item, by id.
"""
if self.item_url :
# separate page
return self.url(self.item_url, id=item.id)
else :
# to our table
return '#{id}'.format(id=item.id)
def render_head (self, filters=None, sort=None) :
"""
Yield header columns in table header.
"""
# id
yield html.th('#')
for column in self.columns :
yield column.render_header(sorturl=self.sorturl(column.attr, sort=sort, filters=filters))
def render_head_filters (self, filters=None) :
"""
Yield filter columns in table header.
"""
# id
yield html.td(html.input(type='submit', value=u'\u00BF'))
for column in self.columns :
yield column.render_header_filter(filters)
def render_cell (self, rowhtml, css=(), filters=None, title=None) :
"""
Render a single cell.
htmlvalue - rendered value
css - css classes to apply
filters - render filter link for filter-values?
title - mouseover title for cell
"""
if filters :
cell = html.a(href=self.tableurl(**filters))(rowhtml)
else :
cell = rowhtml
css = ' '.join(cls for cls in css if cls)
return html.td(class_=css, title=title)(cell)
def render_row (self, item, **opts) :
"""
Yield columns for row.
"""
yield html.th(
html.a(href=self.itemurl(item))("#")
),
for column in self.columns :
yield column.render_cell(item, self, **opts)
def render_body (self, rows, **opts) :
"""
Yield body rows.
"""
for i, item in enumerate(rows) :
yield html.tr(class_=('alternate' if i % 2 else None), id=item.id)(
self.render_row(item, **opts)
)
def render_pagination (self, page, count=None, **opts) :
"""
Render pagination links.
"""
if count is not None :
pages = int(math.ceil(count / self.page))
else :
pages = None
if page > 0 :
yield html.a(href=self.tableurl(page=0, **opts))(html("«« First"))
yield html.a(href=self.tableurl(page=(page - 1), **opts))(html("« Prev"))
if pages :
yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=pages))
else :
yield html.span("Page {page}".format(page=(page + 1)))
yield html.a(href=self.tableurl(page=(page + 1), **opts))(html("» Next"))
def render_foot (self, query, page, **opts) :
"""
Render pagination/host count in footer.
"""
# XXX: does separate SELECT count()
count = query.count()
if page is None :
return "{count} {items}".format(count=count, items=self.ITEMS)
else :
# XXX: count is just the count we got..
return self.render_pagination(page, count=None, **opts)
def render (self, query, filters=None, sort=None, page=None, hilight=None) :
"""
Return <table> element. Wrapped in <form> if filters.
query - filter()'d sort()'d SELECT query()
filters - None for no filtering ui, dict of filters otherwise.
sort - None for no sorting ui, sort-attr otherwise.
page - display pagination for given page
hilight - { attr: value } cells to hilight
"""
# render table
table = html.table(
html.caption(self.caption) if self.caption else None,
html.thead(
html.tr(
self.render_head(filters=filters, sort=sort)
),
(
html.tr(class_='filter')(self.render_head_filters(filters=filters))
) if filters is not None else None,
),
html.tbody(
self.render_body(query,
filters = filters,
hilight = hilight,
)
),
html.tfoot(
html.tr(
html.td(colspan=(1 + len(self.columns)))(
self.render_foot(query, page, filters=filters, sort=sort)
)
)
)
)
# filters form?
if filters is None :
return table
else :
return html.form(method='get', action=self.tableurl())(
html.input(type='hidden', name='sort', value=sort),
table,
)
def json (self, item) :
"""
Yield JSON params for given item.
"""
yield 'id', item.id
for column in self.columns :
value = column.cell_value(item)
yield column.attr, dict(
html = unicode(html(column.cell_html(item, value))),
css = tuple(column.cell_css(item, value)),
title = column.cell_title(item, value),
)
class TableHandler (object) :
"""
Mixin for handling Table args/rendering.
"""
CSS = (
"/static/dhcp/table.css",
)
TABLE = None
DB_TABLE = None
# target Handlers for table links
TABLE_URL = None
TABLE_ITEM_URL = None
def init (self) :
"""
Bind self.table
"""
super(TableHandler, self).init()
self.table = self.TABLE(self.url,
table_url = self.TABLE_URL,
item_url = self.TABLE_ITEM_URL,
)
def query (self) :
"""
Database SELECT query.
"""
if self.DB_CLASS is None :
raise NotImplementedError()
return self.db.query(self.DB_CLASS)
def sort (self, query) :
"""
Apply ?sort= from requset args to query.
Return { attr: sort }, query
"""
sort = self.request.args.get('sort')
if sort :
name = sort.lstrip('+-')
else :
name = None
if name :
order_by = self.TABLE.ATTRS[name].column
else :
order_by = self.TABLE.SORT # default
# prefix -> ordering
if not sort :
pass
elif sort.startswith('+') :
order_by = order_by.asc()
elif sort.startswith('-') :
order_by = order_by.desc()
else :
pass
# apply
log.debug("sort: %s", order_by)
query = query.order_by(order_by)
return sort, query
def filter_attr (self, attr, value) :
"""
Return filter expression for given attr == value
"""
# preprocess
like = False
if value.endswith('*') :
like = value.replace('*', '%')
# filter
column = self.TABLE.ATTRS[attr].column
if like :
return (column.like(like))
else :
return (column == value)
def _filter (self, attr, values) :
"""
Apply filters for given attr -> (value, expression)
"""
for value in values :
value = value.strip()
# ignore empty fields
if not value :
continue
# lookup attr-specific filter
filter = getattr(self, 'filter_{attr}'.format(attr=attr), None)
if filter :
filter = filter(value)
else :
# use generic
filter = self.filter_attr(attr, value)
log.debug("%s: %s: %s", attr, value, filter)
yield value, filter
def filter (self, query) :
"""
Apply filters from request.args against given hosts.
Returns (filters, hosts).
"""
# filter?
filters = {}
for attr in self.TABLE.ATTRS :
# from request args
values = self.request.args.getlist(attr)
# lookup attr filters as expressions
value_filters = list(self._filter(attr, values))
# ignore empty fields
if not value_filters :
continue
# filtering values, and filter expressions
values, expressions = zip(*value_filters)
# apply
query = query.filter(db.or_(*expressions))
filters[attr] = values
return filters, query
def filters_title (self) :
"""
Return a string representing the applied filters.
"""
return ', '.join(value for values in self.filters.itervalues() for value in values)
def title (self) :
if self.filters :
return "{title}: {filters}".format(title=self.TABLE.ITEMS, filters=self.filters_title())
else :
return self.TABLE.ITEMS
def process (self) :
"""
Process request args -> self.filters, self.sorts, self.page, self.query
"""
query = self.query()
# filter
self.filters, query = self.filter(query)
# sort
# TODO: sort per filter column by default?
self.sorts, query = self.sort(query)
# page?
self.page = self.request.args.get('page')
if self.page :
self.page = int(self.page)
query = query.offset(self.page * self.TABLE.PAGE).limit(self.TABLE.PAGE)
self.query = query
def render_table (self, query, filters=None, sort=None, page=None, hilight=None) :
"""
Render table
query - SELECT query for rows
filters - applied filters
sort - applied sort
page - applied page
hilight - hilight given { attr: value } cells
"""
return self.table.render(query,
filters = filters,
sort = sort,
page = page,
hilight = hilight,
)
def render (self) :
return (
self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),
)