--- a/bin/pvl.verkko-dhcp Sat Jan 26 21:23:39 2013 +0200
+++ b/bin/pvl.verkko-dhcp Sat Jan 26 21:24:01 2013 +0200
@@ -5,7 +5,8 @@
from pvl import __version__
import pvl.args
import pvl.web.args
-import pvl.verkko.wsgi
+import pvl.verkko
+import pvl.verkko.dhcp
import optparse
import logging; log = logging.getLogger('main')
@@ -49,8 +50,11 @@
# parse cmdline
options, args = parse_argv(argv, doc=__doc__)
+ # open
+ database = pvl.verkko.Database(options.database_read)
+
# app
- application = pvl.web.args.apply(options, pvl.verkko.wsgi.Application, options.database_read)
+ application = pvl.web.args.apply(options, pvl.verkko.dhcp.Application, database)
# wsgi wrapper
run_simple('0.0.0.0', 8080, application,
--- a/pvl/verkko/__init__.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/__init__.py Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,4 @@
+
+from pvl.verkko import db
+from pvl.verkko.db import Database
+
--- a/pvl/verkko/db.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/db.py Sat Jan 26 21:24:01 2013 +0200
@@ -1,3 +1,7 @@
+"""
+ SQLAlchemy Database tables model for pvl.verkko
+"""
+
import sqlalchemy
from sqlalchemy import *
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/dhcp.py Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,72 @@
+# encoding: utf-8
+import pvl.web
+import pvl.verkko.web
+
+from pvl.web import html, urls
+from pvl.verkko import hosts
+
+import logging; log = logging.getLogger('pvl.verkko.dhcp')
+
+class Index (pvl.verkko.web.DatabaseHandler) :
+ TITLE = u"Päivölä Verkko"
+
+ CSS = pvl.verkko.web.DatabaseHandler.CSS + (
+ '/static/dhcp/forms.css',
+ )
+
+ def render_link (self, title, **opts) :
+ return html.a(href=self.url(hosts.ListHandler, **opts))(title)
+
+ def render_links (self, attr, titlevalues) :
+ return html.ul(
+ html.li(
+ self.render_link(title, **{attr: value})
+ ) for title, value in titlevalues
+ )
+
+ def render (self) :
+ return (
+ html.h2("Interval"),
+ self.render_links('seen', (
+ ("Hour", '1h'),
+ ("Day", '1d'),
+ #("Month", '30d'),
+ #("Year", '365d'),
+ )),
+ html.h2("State"),
+ self.render_links('state', (
+ ("Valid", ('DHCPACK', 'DHCPRELEASE')),
+ ("Incomplete", ('DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST')),
+ ("Invalid", ('DHCPNAK', )),
+ )),
+
+ html.h2("IP/MAC"),
+ html.form(action=self.url(hosts.ListHandler), method='get')(
+ html.fieldset(
+ html.ul(
+ html.li(
+ html.label(for_='ip')("IP"),
+ html.input(type='text', name='ip'),
+ ),
+
+ html.li(
+ html.label(for_='mac')("MAC"),
+ html.input(type='text', name='mac'),
+ ),
+
+ html.li(
+ html.input(type='submit', value="Search"),
+ ),
+ )
+ )
+ ),
+ )
+
+class Application (pvl.verkko.web.Application) :
+ URLS = urls.Map((
+ urls.rule('/', Index),
+ urls.rule('/hosts/', hosts.ListHandler),
+ urls.rule('/hosts/<int:id>', hosts.ItemHandler),
+ urls.rule('/hosts/realtime', hosts.RealtimeHandler),
+ ))
+
--- a/pvl/verkko/hosts.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/hosts.py Sat Jan 26 21:24:01 2013 +0200
@@ -1,4 +1,4 @@
-from pvl.verkko import db, web
+from pvl.verkko import web, db, table
from pvl.verkko.utils import parse_timedelta, IPv4Network
from pvl.web import html
@@ -10,7 +10,17 @@
import logging; log = logging.getLogger('pvl.verkko.hosts')
-# XXX: this should actually be DHCPHost
+## Model
+import json
+import time
+
+def dt2ts (dt) :
+ return int(time.mktime(dt.timetuple()))
+
+def ts2dt (ts) :
+ return datetime.datetime.fromtimestamp(ts)
+
+# TODO: this should be DHCPHost
class Host (object) :
DATE_FMT = '%Y%m%d'
TIME_FMT = '%H:%M:%S'
@@ -54,6 +64,9 @@
else :
return unicode(self.mac)
+ def network (self) :
+ return self.gw
+
def render_name (self) :
if self.name :
return self.name.decode('ascii', 'replace')
@@ -95,7 +108,7 @@
else :
return dt.strftime(cls.TIME_FMT)
-
+
def seen (self) :
return (
html.span(title=self.first_seen)(self.format_datetime(self.first_seen)),
@@ -129,285 +142,102 @@
#state = db.dhcp_hosts.c.,
))
+## Controller
+class HostsTable (table.Table) :
+ """
+ Table of hosts.
+ """
-
-
-class BaseHandler (web.Handler) :
- HOST_ATTRS = {
- 'id': Host.id,
- 'net': Host.gw,
- 'ip': Host.ip,
- 'mac': Host.mac,
- 'name': Host.name,
- 'seen': Host.last_seen,
- 'state': Host.state,
- 'count': Host.count,
- }
+ COLUMNS = (
+ table.Column('ip', "IP", Host.ip,
+ rowfilter = True,
+ ),
+ table.Column('mac', "MAC", Host.mac, Host.render_mac,
+ rowfilter = True,
+ ),
+ table.Column('name', "Hostname", Host.name, Host.render_name, ),
+ table.Column('gw', "Network", Host.gw, Host.network, ),
+ table.Column('seen', "Seen", Host.last_seen, Host.seen, ),
+ table.Column('state', "State", Host.count,
+ rowtitle = Host.state_title,
+ rowcss = Host.state_class,
+ ),
+ )
+
+ # XXX: have to set again
+ ATTRS = dict((col.attr, col) for col in COLUMNS)
- HOST_SORT = Host.last_seen.desc()
+ # XXX: set later
+ TABLE_URL = ITEM_URL = None
+
+ # default
+ SORT = Host.last_seen.desc()
+ PAGE = 10
+
+class HostsHandler (table.TableHandler, web.DatabaseHandler) :
+ """
+ Combined database + <table>
+ """
+
+ CSS = web.DatabaseHandler.CSS + table.TableHandler.CSS + (
+ "/static/dhcp/hosts.css",
+ )
+
+ # model
+ TABLE = HostsTable
def query (self) :
- return self.db.query(Host)
-
- def sort (self, hosts, default=HOST_SORT) :
- sort = self.request.args.get('sort')
-
- if sort :
- name = sort.lstrip('+-')
- else :
- name = None
+ """
+ Database SELECT query.
+ """
- if name :
- order_by = self.HOST_ATTRS[name]
- else :
- order_by = default
-
- # prefix
- if not sort :
- pass
- elif sort.startswith('+') :
- order_by = order_by.asc()
- elif sort.startswith('-') :
- order_by = order_by.desc()
- else :
- pass
+ return self.db.query(Host)
- log.debug("sort: %s", order_by)
-
- hosts = hosts.order_by(order_by)
-
- # k
- return sort, hosts
-
- def filter_attr (self, attr, value) :
+ def filter_seen (self, value) :
"""
Return filter expression for given attr == value
"""
- if attr == 'seen' :
- column = Host.last_seen
-
- if value.isdigit() :
- # specific date
- date = datetime.datetime.strptime(value, Host.DATE_FMT).date()
-
- return db.between(date.strftime(Host.DATE_FMT),
- db.func.strftime(Host.DATE_FMT, Host.first_seen),
- db.func.strftime(Host.DATE_FMT, Host.last_seen)
- )
- else :
- # recent
- timedelta = parse_timedelta(value)
-
- return ((db.func.now() - Host.last_seen) < timedelta)
-
- # XXX: for sqlite, pgsql should handle this natively?
- # to seconds
- #timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds
-
- # WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout
- #filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout)
-
- elif attr == 'ip' :
- column = Host.ip
-
- # column is IPv4 string literal format...
- if '/' in value :
- return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value)))
- else :
- return (db.func.inet(Host.ip) == db.func.inet(value))
-
- else :
- # preprocess
- like = False
-
- if value.endswith('*') :
- like = value.replace('*', '%')
-
- elif attr == 'mac' :
- value = Host.normalize_mac(value)
-
- # filter
- column = self.HOST_ATTRS[attr]
-
- if like :
- return (column.like(like))
- else :
- return (column == value)
-
- def filter (self, hosts) :
- """
- Apply filters from request.args against given hosts.
-
- Returns (filters, hosts).
- """
-
- # filter?
- filters = {}
-
- for attr in self.HOST_ATTRS :
- values = [value.strip() for value in self.request.args.getlist(attr) if value.strip()]
-
- # ignore empty fields
- if not values :
- continue
-
- # build query expression
- filter = db.or_(*[self.filter_attr(attr, value) for value in values])
-
- log.debug("filter %s: %s", attr, filter)
-
- hosts = hosts.filter(filter)
- filters[attr] = values
-
- return filters, hosts
-
- 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 render_hosts (self, hosts, title=None, filters=False, page=None, hilight=None) :
- COLS = (
- #title sort filter class
- ('IP', 'ip', 'ip', 'ip' ),
- ('MAC', 'mac', 'mac', 'mac' ),
- ('Hostname', 'name', False, False ),
- ('Network', 'net', 'net', False ),
- ('Seen', 'seen', 'seen', 'seen' ),
- ('State', 'state', 'state', False ),
- )
-
- def url (**opts) :
- args = dict()
-
- if filters :
- args.update(filters)
-
- args.update(opts)
-
- return self.url(**args)
+ column = Host.last_seen
- def sortlink (attr) :
- if not self.sorts :
- sort = attr
- elif self.sorts.lstrip('+-') != attr :
- sort = attr
- elif self.sorts.startswith('-') :
- sort = "+" + attr
- else :
- sort = "-" + attr
-
- return html.a(href=url(sort=sort))
-
- def paginate (page, count=None) :
- """
- Render pagination.
- """
-
- if count is not None :
- pages = int(math.ceil(count / self.PAGE)) # XXX: bad self.PAGE
- else :
- pages = None
-
- if page > 0 :
- yield html.a(href=url(page=0))(html("«« First"))
- yield html.a(href=url(page=(page - 1)))(html("« Prev"))
-
- yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???')))
-
- yield html.a(href=url(page=(page + 1)))(html("» Next"))
-
- def render_filter (filter) :
- value = filters.get(filter)
-
- if value :
- # XXX: multi-valued filters?
- value = value[0]
- else :
- value = None
-
- return html.input(type='text', name=filter, value=value)
-
- def render_cell (attr, value, cssclass=True, filter=None, htmlvalue=None) :
- if htmlvalue :
- cell = htmlvalue
- else :
- cell = value
-
- if filter :
- cell = html.a(href=self.url(ListHandler, **{attr: value}))(cell)
-
- if cssclass is True :
- cssclass = attr
-
- css = (cssclass, 'hilight' if (hilight and attr in hilight and value in hilight[attr]) else None)
- css = ' '.join(cls for cls in css if cls)
-
- return html.td(class_=css)(cell)
+ if value.isdigit() :
+ # specific date
+ date = datetime.datetime.strptime(value, Host.DATE_FMT).date()
- table = html.table(
- html.caption(title) if title else None,
- html.thead(
- html.tr(
- html.th('#'),
- (
- html.th(
- sortlink(sort)(title) if sort else (title)
- ) for title, sort, filter, class_ in COLS
- )
- ),
- html.tr(class_='filter')(
- html.td(
- html.input(type='submit', value=u'\u00BF'),
- ),
- (
- html.td(class_=class_)(
- render_filter(filter) if filter else None
- ) for title, sort, filter, class_ in COLS
- )
- ) if filters is not False else None
- ),
- html.tbody(
- html.tr(class_=('alternate' if i % 2 else None), id=host.id)(
- html.th(
- html.a(href=self.url(ItemHandler, id=host.id))(
- '#' #host.id
- )
- ),
+ return db.between(date.strftime(Host.DATE_FMT),
+ db.func.strftime(Host.DATE_FMT, Host.first_seen),
+ db.func.strftime(Host.DATE_FMT, Host.last_seen)
+ )
+ else :
+ # recent
+ timedelta = parse_timedelta(value)
+
+ return ((db.func.now() - Host.last_seen) < timedelta)
- render_cell('ip', host.ip, filter=True),
- render_cell('mac', host.mac, filter=True, htmlvalue=host.render_mac()),
- render_cell('name', host.name, htmlvalue=host.render_name()),
- render_cell('gw', host.gw),
+ # XXX: for sqlite, pgsql should handle this natively?
+ # to seconds
+ #timeout = timedelta.days * (24 * 60 * 60) + timedelta.seconds
+
+ # WHERE strftime('%s', 'now') - strftime('%s', last_seen) < :timeout
+ #filter = (db.func.strftime('%s', 'now') - db.func.strftime('%s', Host.last_seen) < timeout)
+
+ def filter_ip (self, value) :
+ column = Host.ip
- render_cell('seen', host.seen()),
- html.td(class_=host.state_class(), title=host.state_title())(host.state),
- ) for i, host in enumerate(hosts)
- ),
- html.tfoot(
- html.tr(
- html.td(colspan=(1 + len(COLS)))(
- paginate(page) if page is not None else (
- # XXX: does separate SELECT count()
- "{count} hosts".format(count=hosts.count())
- )
- )
- )
- )
- )
-
- if filters is False :
- return table
+ # column is IPv4 string literal format...
+ if '/' in value :
+ return (db.func.inet(Host.ip).op('<<')(db.func.cidr(value)))
else :
- return html.form(method='get', action=self.url())(
- html.input(type='hidden', name='sort', value=self.sorts),
- table,
- )
+ return (db.func.inet(Host.ip) == db.func.inet(value))
-class ItemHandler (BaseHandler) :
+ def filter_mac (self, value) :
+ return self.filter_attr('mac', Host.normalize_mac(value))
+
+class ItemHandler (HostsHandler) :
+ """
+ A specific DHCP host, along with a list of related hosts.
+ """
+
def process (self, id) :
self.hosts = self.query()
self.host = self.hosts.get(id)
@@ -449,80 +279,38 @@
self.render_host(self.host),
html.h2('Related'),
- self.render_hosts(self.hosts, hilight=dict(ip=self.host.ip, mac=self.host.mac)),
+ self.render_table(self.hosts, sort=self.sorts, hilight=dict(ip=self.host.ip, mac=self.host.mac)),
html.a(href=self.url(ListHandler))(html('«'), 'Back'),
)
-
-class ListHandler (BaseHandler) :
- # pagination
- PAGE = 10
+class ListHandler (HostsHandler) :
+ """
+ List of DHCP hosts for given filter.
+ """
- # views
- VIEWS = (
- ("Last hour", dict(seen='1h')),
- ("Last day", dict(seen='24h')),
- ("All", dict()),
- ) + tuple(
- ("Network " + network, dict(ip=network)) for network in (
- '194.197.235.0/24',
- '10.1.0.0/16',
- '10.4.0.0/16',
- '10.5.0.0/16',
- '10.6.0.0/16',
- '10.10.0.0/16',
- )
- ) + (
- ("Valid", dict(state=('DHCPACK', 'DHCPRELEASE'))),
- ("Incomplete", dict(state=('DHCPDISCOVER', 'DHCPOFFER', 'DHCPREQUEST'))),
- ("Invalid", dict(state=('DHCPNAK', ))),
- )
+ TABLE_ITEM_URL = ItemHandler
def process (self) :
- hosts = self.query()
-
- # filter
- self.filters, hosts = self.filter(hosts)
-
- # sort XXX: default per filter column?
- self.sorts, hosts = self.sort(hosts)
-
- # page?
- self.page = self.request.args.get('page')
-
- if self.page :
- self.page = int(self.page)
-
- hosts = hosts.offset(self.page * self.PAGE).limit(self.PAGE)
-
- self.hosts = hosts
-
+ # super
+ table.TableHandler.process(self)
+
def title (self) :
if self.filters :
return "DHCP Hosts: {filters}".format(filters=self.filters_title())
else :
return "DHCP Hosts"
-
+
def render (self) :
return (
- self.render_hosts(self.hosts, filters=self.filters, page=self.page),
+ self.render_table(self.query, filters=self.filters, sort=self.sorts, page=self.page),
- html.a(href=self.url())(html('«'), 'Back') if self.filters else None,
+ #html.a(href=self.url())(html('«'), 'Back') if self.filters else None,
)
-import json
-import time
-
-def dt2ts (dt) :
- return int(time.mktime(dt.timetuple()))
-
-def ts2dt (ts) :
- return datetime.datetime.fromtimestamp(ts)
-
-class RealtimeHandler (BaseHandler) :
- TITLE = "Pseudo-Realtime hosts.."
- CSS = web.Handler.CSS + (
+class RealtimeHandler (HostsHandler) :
+ TITLE = "DHCP Pseudo-Realtime hosts.."
+ CSS = HostsHandler.CSS + (
'http://code.jquery.com/ui/1.9.0/themes/base/jquery-ui.css',
)
JS = (
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/table.py Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,526 @@
+from pvl.web import html
+from pvl.verkko import web, db
+
+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 render_cell (self, item, table, hilight=None) :
+ """
+ Render <td> for item in <tbody> in given Table.
+
+ hilight - optionally higlight given { attr: value }'s using CSS.
+ """
+
+ # XXX: this is sometimes broken, figure out how to index by column
+ value = getattr(item, self.attr)
+
+ if self.rowhtml :
+ rowhtml = self.rowhtml(item)
+ else :
+ rowhtml = value
+
+ if self.rowcss :
+ rowcss = self.rowcss(item)
+ else :
+ rowcss = None
+
+ if hilight :
+ # lookup attr/value
+ hilight = self.attr in hilight and value in hilight[self.attr]
+
+ if self.rowfilter :
+ # filter-link by row-value
+ filters = { self.attr: value }
+ else :
+ filters = None
+
+ # css classes to apply, or None's
+ css = (
+ self.colcss,
+ rowcss,
+ 'hilight' if hilight else None
+ )
+
+ if self.rowtitle :
+ rowtitle = self.rowtitle(item)
+ else :
+ rowtitle = None
+
+ yield table.render_cell(rowhtml,
+ css = css,
+ filters = filters,
+ rowtitle = rowtitle,
+ )
+
+class Table (object) :
+ """
+ Render <table> with Columns from SQLAlchemy ORM model class.
+ """
+
+ COLUMNS = ()
+
+ # 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, rowtitle=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=rowtitle)(cell)
+
+ def render_row (self, item, **opts) :
+ """
+ Yield columns for row.
+ """
+
+ 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)(
+ html.th(
+ html.a(href=self.itemurl(item))("#")
+ ),
+
+ self.render_row(item, **opts)
+ )
+
+ def render_pagination (self, page, count=None) :
+ """
+ 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))(html("«« First"))
+ yield html.a(href=self.tableurl(page=(page - 1)))(html("« Prev"))
+
+ yield html.span("Page {page} of {pages}".format(page=(page + 1), pages=(pages or '???')))
+
+ yield html.a(href=self.tableurl(page=(page + 1)))(html("» Next"))
+
+ def render_foot (self, query, page) :
+ """
+ Render pagination/host count in footer.
+ """
+
+ # XXX: does separate SELECT count()
+ count = query.count()
+
+ if page :
+ return self.render_pagination(page, count)
+ else :
+ return "{count} hosts".format(count=count)
+
+ 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,
+ hilight = hilight,
+ )
+ ),
+ html.tfoot(
+ html.tr(
+ html.td(colspan=(1 + len(self.columns)))(
+ self.render_foot(query, page)
+ )
+ )
+ )
+ )
+
+ # 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,
+ )
+
+class TableHandler (object) :
+ """
+ Mixin for handling Table args/rendering.
+ """
+
+ CSS = (
+ "/static/dhcp/table.css",
+ )
+
+ TABLE = None
+
+ # target Handlers for table links
+ TABLE_URL = None
+ TABLE_ITEM_URL = None
+
+ def query (self) :
+ """
+ Database SELECT query.
+ """
+
+ raise NotImplementedError()
+
+ 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 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(self.url,
+ table_url = self.TABLE_URL,
+ item_url = self.TABLE_ITEM_URL,
+ ).render(query,
+ filters = filters,
+ sort = sort,
+ page = page,
+ hilight = hilight,
+ )
+
--- a/pvl/verkko/urls.py Sat Jan 26 21:23:39 2013 +0200
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-from werkzeug.routing import Map, Rule
-
-def rule (string, endpoint, **opts) :
- return Rule(string, endpoint=endpoint, **opts)
-
-# URL -> Handler
-from pvl.verkko import hosts
-
-# index page here :)
-from pvl.verkko import web
-
-class Index (web.Handler) :
- def render (self) :
- html = web.html
-
- return (
- html.ul(
- html.li(
- "DHCP Hosts",
- html.ul(
- html.li(html.a(href=self.url(hosts.ListHandler, **opts))(title)) for title, opts in hosts.ListHandler.VIEWS
- )
- ),
- )
- )
-
-urls = Map((
- rule('/', Index),
- rule('/hosts/', hosts.ListHandler),
- rule('/hosts/<int:id>', hosts.ItemHandler),
- rule('/hosts/realtime', hosts.RealtimeHandler),
-))
-
--- a/pvl/verkko/web.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/verkko/web.py Sat Jan 26 21:24:01 2013 +0200
@@ -1,41 +1,20 @@
# encoding: utf-8
-import pvl.web.application
-
-# view
-from pvl.web.html import tags as html
+import pvl.web
+from pvl.web import html, urls
-from werkzeug.wrappers import Response
-from werkzeug.exceptions import (
- HTTPException,
- BadRequest, # 400
- NotFound, # 404
-)
-from werkzeug.utils import redirect
+import logging; log = logging.getLogger('pvl.verkko.web')
-class Handler (pvl.web.application.Handler) :
- CSS = (
- "/static/dhcp/hosts.css",
- )
- JS = (
- #"/static/jquery/jquery.js"
- )
+class DatabaseHandler (pvl.web.Handler) :
+ """
+ Request handler with pvl.verkko.Database session
+ """
def __init__ (self, app, request, urls, params) :
- super(Handler, self).__init__(app, request, urls, params)
+ super(DatabaseHandler, self).__init__(app, request, urls, params)
# new ORM session per request
self.db = app.db.session()
- def title (self) :
- """
- Render site/page title as text.
- """
-
- if self.TITLE :
- return u"Päivölä Verkko :: {title}".format(title=self.TITLE)
- else :
- return u"Päivölä Verkko"
-
def cleanup (self) :
"""
After request processing.
@@ -43,3 +22,18 @@
# XXX: SQLAlchemy doesn't automatically close these...?
self.db.close()
+
+class Application (pvl.web.Application) :
+ """
+ Application with pvl.verkko.Database
+ """
+
+ def __init__ (self, db, **opts) :
+ """
+ db - pvl.verkko.Database
+ """
+
+ super(Application, self).__init__(**opts)
+
+ self.db = db
+
--- a/pvl/verkko/wsgi.py Sat Jan 26 21:23:39 2013 +0200
+++ /dev/null Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-import pvl.web
-
-import logging; log = logging.getLogger('pvl.verkko.wsgi')
-
-from pvl.verkko import urls, db as database
-
-class Application (pvl.web.Application) :
- URLS = urls.urls
-
- def __init__ (self, db, **opts) :
- """
- Initialize app with db.
- """
-
- super(Application, self).__init__(**opts)
-
- self.db = database.Database(db)
-
--- a/pvl/web/application.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/web/application.py Sat Jan 26 21:24:01 2013 +0200
@@ -1,5 +1,5 @@
"""
- WSGI Application.
+ WSGI Application with pvl.web.urls-mapped Handlers building pvl.web.html.
"""
import werkzeug
@@ -135,6 +135,9 @@
)
body = html(self.render())
+ if not title :
+ raise Exception("%s: no page title!" % (self, ))
+
if self.app.layout :
return self.app.layout.format(
TITLE = unicode(title),
--- a/pvl/web/html.py Sat Jan 26 21:23:39 2013 +0200
+++ b/pvl/web/html.py Sat Jan 26 21:24:01 2013 +0200
@@ -7,6 +7,7 @@
"""
# XXX: needs some refactoring for Text vs Tag now
+# XXX: not all tags work in self-closing form, e.g. empty html.title() breaks badly
import itertools as itertools
import types as types
--- a/setup_verkko.py Sat Jan 26 21:23:39 2013 +0200
+++ b/setup_verkko.py Sat Jan 26 21:24:01 2013 +0200
@@ -17,6 +17,7 @@
packages = [
'pvl',
'pvl.syslog',
+ 'pvl.dhcp',
'pvl.rrd',
'pvl.web',
'pvl.verkko',
--- a/static/dhcp/hosts.css Sat Jan 26 21:23:39 2013 +0200
+++ b/static/dhcp/hosts.css Sat Jan 26 21:24:01 2013 +0200
@@ -7,136 +7,6 @@
}
/*
- * Tables
- */
-table
-{
- width: 80%;
-
- margin: auto;
- padding: 0px;
-
- border-collapse: collapse;
- border: 1px solid #aaa;
-
- font-size: small;
-}
-
-/* A caption looks similar to a h2 */
-table caption
-{
- font-size: large;
- font-weight: bold;
-
- padding: 0.5em;
- margin: 0em 0em 0.5em;
-
- background-color: #e5e5e5;
- border: 1px dashed #c5c5c5;
-}
-
-/* Table header */
-thead tr
-{
- background-color: #d0d0d0;
-}
-
-thead
-{
- border-bottom: 2px solid #aaa;
-}
-
-/* Filter */
-tr.filter
-{
- height: 1em;
-}
-
-tr.filter td
-{
- padding: 0em 1em;
-}
-
-tr.filter td input
-{
- display: block;
- width: 100%;
-
- border: 0px;
- padding: 0em;
-
- background-color: #d0d0d0;
-
- font-family: inherit;
- font-size: inherit;
-}
-
-tr.filter td input:hover
-{
- background-color: #e0e0e0;
-}
-
-tr.filter td input:focus
-{
- background-color: #ffffff;
-}
-
-/* Rows */
-tr
-{
- background-color: #e0e0e0;
-}
-
-tr.alternate
-{
- background-color: #c8c8c8;
-}
-
-/* Link to tr with URL #foo fragment */
-tr:target
-{
- background-color: #F5F3B8;
-}
-
-/* Cells */
-td, th
-{
- border: thin solid #ffffff;
-
- padding: 0.25em 1em;
-
- /* Do not normally wrap */
- white-space: nowrap;
-}
-
-thead a,
-tbody a
-{
- display: block;
-}
-
-th
-{
- text-align: center;
-}
-
-td.hilight
-{
- font-weight: bold;
-}
-
-/* Footer */
-tfoot
-{
- border-top: 2px solid #aaa;
-}
-
-tfoot tr
-{
- text-align: center;
-}
-
-/*
* Details
*/
div.info {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/dhcp/table.css Sat Jan 26 21:24:01 2013 +0200
@@ -0,0 +1,130 @@
+/*
+ * Tables
+ */
+table
+{
+ width: 80%;
+
+ margin: auto;
+ padding: 0px;
+
+ border-collapse: collapse;
+ border: 1px solid #aaa;
+
+ font-size: small;
+}
+
+/* A caption looks similar to a h2 */
+table caption
+{
+ font-size: large;
+ font-weight: bold;
+
+ padding: 0.5em;
+ margin: 0em 0em 0.5em;
+
+ background-color: #e5e5e5;
+ border: 1px dashed #c5c5c5;
+}
+
+/* Table header */
+thead tr
+{
+ background-color: #d0d0d0;
+}
+
+thead
+{
+ border-bottom: 2px solid #aaa;
+}
+
+/* Filter */
+tr.filter
+{
+ height: 1em;
+}
+
+tr.filter td
+{
+ padding: 0em 1em;
+}
+
+tr.filter td input
+{
+ display: block;
+ width: 100%;
+
+ border: 0px;
+ padding: 0em;
+
+ background-color: #d0d0d0;
+
+ font-family: inherit;
+ font-size: inherit;
+}
+
+tr.filter td input:hover
+{
+ background-color: #e0e0e0;
+}
+
+tr.filter td input:focus
+{
+ background-color: #ffffff;
+}
+
+/* Rows */
+tr
+{
+ background-color: #e0e0e0;
+}
+
+tr.alternate
+{
+ background-color: #c8c8c8;
+}
+
+/* Link to tr with URL #foo fragment */
+tr:target
+{
+ background-color: #F5F3B8;
+}
+
+/* Cells */
+td, th
+{
+ border: thin solid #ffffff;
+
+ padding: 0.25em 1em;
+
+ /* Do not normally wrap */
+ white-space: nowrap;
+}
+
+thead a,
+tbody a
+{
+ display: block;
+}
+
+th
+{
+ text-align: center;
+}
+
+td.hilight
+{
+ font-weight: bold;
+}
+
+/* Footer */
+tfoot
+{
+ border-top: 2px solid #aaa;
+}
+
+tfoot tr
+{
+ text-align: center;
+}
+