initial code for dhcp hosts
authorTero Marttila <terom@paivola.fi>
Wed, 10 Oct 2012 21:39:54 +0300
changeset 0 91c739202f06
child 1 731d2df704f0
initial code for dhcp hosts
.hgignore
pvl/__init__.py
pvl/args.py
pvl/html.py
pvl/verkko/__init__.py
pvl/verkko/db.py
pvl/verkko/hosts.py
pvl/verkko/web.py
pvl/verkko/wsgi.py
static/style.css
test.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,7 @@
+syntax: regexp
+
+# tempfiles
+\.pyc$
+\.sw[op]$
+
+^var/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/__init__.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,2 @@
+
+__version__ = '0.0'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/args.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,50 @@
+"""
+    CLI argument handling; common stuff: logging
+"""
+
+import optparse
+import logging
+
+def parser (parser) :
+    """
+        Return an optparse.OptionGroup.
+    """
+
+    general = optparse.OptionGroup(parser, "General options")
+
+    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
+    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
+    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
+    general.add_option('--debug-module',    action='append', metavar='MODULE', 
+            help="Enable logging for the given logger/module name")
+
+    # defaults
+    parser.set_defaults(
+        logname             = parser.prog,
+        loglevel            = logging.WARN,
+        debug_module        = [],
+    )
+ 
+    return general
+
+def apply (options, logname=None) :
+    """
+        Apply the optparse options.
+    """
+
+    if logname :
+        prefix = options.logname + ': '
+    else :
+        prefix = ''
+
+    # configure
+    logging.basicConfig(
+        # XXX: log Class.__init__ as Class, not __init__?
+        format  = prefix + '%(name)-20s: %(levelname)5s %(funcName)s: %(message)s',
+        level   = options.loglevel,
+    )
+
+    # enable debugging for specific targets
+    for logger in options.debug_module :
+        logging.getLogger(logger).setLevel(logging.DEBUG)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/html.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,607 @@
+"""
+    Generate XHTML output from python code.
+
+    >>> from html import tags
+    >>> unicode(tags.a(href="http://www.google.com")("Google <this>!"))
+    u'<a href="http://www.google.com">\\n\\tGoogle &lt;this&gt;!\\n</a>'
+"""
+
+import itertools as itertools
+import types as types
+from xml.sax import saxutils
+
+class Renderable (object) :
+    """
+        Structured data that's flattened into indented lines of text.
+    """
+
+    def flatten (self) :
+        """
+            Flatten this object into a series of (identlevel, line) tuples.
+        """
+        
+        raise NotImplementedError()
+
+    def iter (self, indent='\t') :
+        """
+            Yield a series of lines for this render.
+        """
+        
+        for indent_level, line in self.flatten() :
+            yield (indent * indent_level) + line
+
+    def unicode (self, newline=u'\n', **opts) :
+        """
+            Render as a single unicode string.
+
+            No newline is returned at the end of the string.
+
+            >>> Tag.build('a', 'b').unicode(newline='X', indent='Y')
+            u'<a>XYbX</a>'
+        """
+
+        return newline.join(self.iter(**opts))
+    
+    # required for print
+    def str (self, newline='\n', encoding='ascii', **opts) :
+        """
+            Render as a single string.
+        """
+        
+        # XXX: try and render as non-unicode, i.e. binary data in the tree?
+        return newline.join(line.encode(encoding) for line in self.iter(**opts))
+    
+    # formal interface using defaults
+    __iter__ = iter
+    __unicode__ = unicode
+    __str__ = str
+
+class Text (Renderable) :
+    """
+        Plain un-structured/un-processed HTML text for output
+        
+        >>> Text('foo')
+        Text(u'foo')
+        >>> list(Text('<foo>'))
+        [u'<foo>']
+        >>> list(tag('a', Text('<foo>')))
+        [u'<a>', u'\\t<foo>', u'</a>']
+    """
+
+    def __init__ (self, text) :
+        self.text = unicode(text)
+    
+    def flatten (self) :
+        yield (0, self.text)
+
+    def __repr__ (self) :
+        return "Text(%r)" % (self.text, )
+
+class Tag (Renderable) :
+    """
+        An immutable HTML tag structure, with the tag's name, attributes and contents.
+    """
+    
+    # types of nested items to flatten
+    CONTAINER_TYPES = (types.TupleType, types.ListType, types.GeneratorType)
+
+    # types of items to keep as-is
+    ITEM_TYPES = (Renderable, )
+
+    @classmethod
+    def process_contents (cls, *args) :
+        """
+            Yield the HTML tag's contents from the given sequence of positional arguments as a series of flattened
+            items, eagerly converting them to unicode.
+
+            If no arguments are given, we don't have any children:
+            
+            >>> bool(list(Tag.process_contents()))
+            False
+            
+            Items that are None will be ignored:
+
+            >>> list(Tag.process_contents(None))
+            []
+
+            Various Python container types are recursively flattened:
+
+            >>> list(Tag.process_contents([1, 2]))
+            [u'1', u'2']
+            >>> list(Tag.process_contents([1], [2]))
+            [u'1', u'2']
+            >>> list(Tag.process_contents([1, [2]]))
+            [u'1', u'2']
+            >>> list(Tag.process_contents(n + 1 for n in xrange(2)))
+            [u'1', u'2']
+            >>> list(Tag.process_contents((1, 2)))
+            [u'1', u'2']
+            >>> list(Tag.process_contents((1), (2, )))
+            [u'1', u'2']
+
+            Our own HTML-aware objects are returned as-is:
+            
+            >>> list(Tag.process_contents(Tag.build('foo')))
+            [tag('foo')]
+            >>> list(Tag.process_contents(Text('bar')))
+            [Text(u'bar')]
+            
+            All other objects are converted to unicode:
+            
+            >>> list(Tag.process_contents('foo', u'bar', 0.123, False))
+            [u'foo', u'bar', u'0.123', u'False']
+
+        """
+
+        for arg in args :
+            if arg is None :
+                # skip null: None
+                continue
+            
+            elif isinstance(arg, cls.CONTAINER_TYPES) :
+                # flatten nested container: tuple/list/generator
+                for node in arg :
+                    # recurse
+                    for item in cls.process_contents(node) :
+                        yield item
+
+            elif isinstance(arg, cls.ITEM_TYPES) :
+                # yield item: Renderable
+                yield arg
+
+            else :
+                # as unicode
+                yield unicode(arg)
+
+    @classmethod
+    def process_attrs (cls, **kwargs) :
+        """
+            Yield the HTML tag attributes from the given set of keyword arguments as a series of (name, value) tuples.
+            
+            Keyword-only options (`_key=value`) are filtered out:
+                
+            >>> dict(Tag.process_attrs(_opt=True))
+            {}
+
+            Attributes with a value of None/False are filtered out:
+
+            >>> dict(Tag.process_attrs(foo=None, bar=False))
+            {}
+            
+            A value given as True is returned as the key's value:
+
+            >>> dict(Tag.process_attrs(quux=True))
+            {'quux': u'quux'}
+
+            A (single) trailing underscore in the attribute name is removed:
+
+            >>> dict(Tag.process_attrs(class_='foo'))
+            {'class': u'foo'}
+            >>> dict(Tag.process_attrs(data__='foo'))
+            {'data_': u'foo'}
+        """
+
+        for key, value in kwargs.iteritems() :
+            # keyword arguments are always pure strings
+            assert type(key) is str
+
+            if value is None or value is False:
+                # omit
+                continue
+            
+            if key.startswith('_') :
+                # option
+                continue
+
+            if key.endswith('_') :
+                # strip underscore
+                key = key[:-1]
+            
+            if value is True :
+                # flag attr
+                value = key
+            
+            yield key, unicode(value)
+        
+    @classmethod
+    def process_opts (cls, **kwargs) :
+        """
+            Return a series of of the keyword-only _options, extracted from the given dict of keyword arguments, as 
+            (k, v) tuples.
+
+            >>> Tag.process_opts(foo='bar', _bar=False)
+            (('bar', False),)
+        """
+        
+        return tuple((k.lstrip('_'), v) for k, v in kwargs.iteritems() if k.startswith('_'))
+    
+    @classmethod
+    def build (cls, _name, *args, **kwargs) :
+        """
+            Factory function for constructing Tags by directly passing in contents/attributes/options as Python function
+            arguments/keyword arguments.
+
+            The first positional argument is the tag's name:
+            
+            >>> Tag.build('foo')
+            tag('foo')
+            
+            Further positional arguments are the tag's contents:
+
+            >>> Tag.build('foo', 'quux', 'bar')
+            tag('foo', u'quux', u'bar')
+
+            All the rules used by process_contents() are available:
+            
+            >>> Tag.build('foo', [1, None], None, (n for n in xrange(2)))
+            tag('foo', u'1', u'0', u'1')
+
+            The special-case for a genexp as the only argument works:
+            
+            >>> f = lambda *args: Tag.build('foo', *args)
+            >>> f('hi' for n in xrange(2))
+            tag('foo', u'hi', u'hi')
+            
+            Attributes are passed as keyword arguments, with or without contents:
+
+            >>> Tag.build('foo', id=1)
+            tag('foo', id=u'1')
+            >>> Tag.build('foo', 'quux', bar=5)
+            tag('foo', u'quux', bar=u'5')
+            >>> Tag.build('foo', class_='ten')
+            tag('foo', class=u'ten')
+            
+            The attribute names don't conflict with positional argument names:
+
+            >>> Tag.build('bar', name='foo')
+            tag('bar', name=u'foo')
+
+            Options are handled as the 'real' keyword arguments:
+
+            >>> print Tag.build('foo', _selfclosing=False)
+            <foo></foo>
+            >>> print Tag.build('foo', _foo='bar')
+            Traceback (most recent call last):
+                ...
+            TypeError: __init__() got an unexpected keyword argument 'foo'
+        """
+
+        # pre-process incoming user values
+        contents = list(cls.process_contents(*args))
+        attrs = dict(cls.process_attrs(**kwargs))
+
+        # XXX: use Python 2.6 keyword-only arguments instead?
+        options = dict(cls.process_opts(**kwargs))
+
+        return cls(_name, contents, attrs, **options)
+
+    def __init__ (self, name, contents, attrs, selfclosing=None, whitespace_sensitive=None) :
+        """
+            Initialize internal Tag state with the given tag identifier, flattened list of content items, dict of
+            attributes and dict of options.
+
+                selfclosing             - set to False to render empty tags as <foo></foo> instead of <foo /> 
+                                          (for XHTML -> HTML compatibility)
+
+                whitespace_sensitive    - do not indent tag content onto separate rows, render the full tag as a single
+                                          row
+
+            Use the build() factory function to build Tag objects using Python's function call argument semantics.
+            
+            The tag name is used a pure string identifier:
+
+            >>> Tag(u'foo', [], {})
+            tag('foo')
+            >>> Tag(u'\\xE4', [], {})
+            Traceback (most recent call last):
+                ...
+            UnicodeEncodeError: 'ascii' codec can't encode character u'\\xe4' in position 0: ordinal not in range(128)
+
+            Contents have their order preserved:
+
+            >>> Tag('foo', [1, 2], {})
+            tag('foo', 1, 2)
+            >>> Tag('foo', [2, 1], {})
+            tag('foo', 2, 1)
+
+            Attributes can be given:
+            
+            >>> Tag('foo', [], dict(foo='bar'))
+            tag('foo', foo='bar')
+
+            Options can be given:
+
+            >>> print Tag('foo', [], {}, selfclosing=False)
+            <foo></foo>
+        """
+        
+        self.name = str(name)
+        self.contents = contents
+        self.attrs = attrs
+
+        # options
+        self.selfclosing = selfclosing
+        self.whitespace_sensitive = whitespace_sensitive
+
+    def __call__ (self, *args, **kwargs) :
+        """
+            Return a new Tag as a copy of this tag, but with the given additional attributes/contents.
+
+            The same rules for function positional/keyword arguments apply as for build()
+
+            >>> Tag.build('foo')('bar')
+            tag('foo', u'bar')
+            >>> Tag.build('a', href='index.html')("Home")
+            tag('a', u'Home', href=u'index.html')
+
+            New contents and attributes can be given freely, using the same rules as for Tag.build:
+
+            >>> Tag.build('bar', None)(5, foo=None, class_='bar')
+            tag('bar', u'5', class=u'bar')
+
+            Tag contents accumulate in order:
+
+            >>> Tag.build('a')('b', ['c'])('d')
+            tag('a', u'b', u'c', u'd')
+
+            Each Tag is immutable, so the called Tag isn't changed, but rather a copy is returned:
+
+            >>> t1 = Tag.build('a'); t2 = t1('b'); t1
+            tag('a')
+
+            Attribute values are replaced:
+
+            >>> Tag.build('foo', a=2)(a=3)
+            tag('foo', a=u'3')
+
+            Options are also supported:
+
+            >>> list(Tag.build('foo')(bar='quux', _selfclosing=False))
+            [u'<foo bar="quux"></foo>']
+        """
+
+        # accumulate contents
+        contents = self.contents + list(self.process_contents(*args))
+
+        # merge attrs
+        attrs = dict(self.attrs)
+        attrs.update(self.process_attrs(**kwargs))
+
+        # options
+        opts = dict(
+            selfclosing = self.selfclosing,
+            whitespace_sensitive = self.whitespace_sensitive,
+        )
+        opts.update(self.process_opts(**kwargs))
+
+        # build updated tag
+        return Tag(self.name, contents, attrs, **opts)
+
+    def render_attrs (self) :
+        """
+            Return the HTML attributes string
+
+            >>> Tag.build('x', foo=5, bar='<', quux=None).render_attrs()
+            u'foo="5" bar="&lt;"'
+            >>> Tag.build('x', foo='a"b').render_attrs()
+            u'foo=\\'a"b\\''
+        """
+
+        return " ".join(
+            (
+                u'%s=%s' % (name, saxutils.quoteattr(value))
+            ) for name, value in self.attrs.iteritems()
+        )
+
+    def flatten_items (self, indent=1) :
+        """
+            Flatten our content into a series of indented lines.
+
+            >>> list(Tag.build('tag', 5).flatten_items())
+            [(1, u'5')]
+            >>> list(Tag.build('tag', 'line1', 'line2').flatten_items())
+            [(1, u'line1'), (1, u'line2')]
+
+            Nested :
+            >>> list(Tag.build('tag', 'a', Tag.build('b', 'bb'), 'c').flatten_items())
+            [(1, u'a'), (1, u'<b>'), (2, u'bb'), (1, u'</b>'), (1, u'c')]
+            >>> list(Tag.build('tag', Tag.build('hr'), Tag.build('foo')('bar')).flatten_items())
+            [(1, u'<hr />'), (1, u'<foo>'), (2, u'bar'), (1, u'</foo>')]
+        """
+
+        for item in self.contents :
+            if isinstance(item, Renderable) :
+                # recursively flatten items
+                for line_indent, line in item.flatten() :
+                    # indented
+                    yield indent + line_indent, line
+
+            else :
+                # render HTML-escaped raw value
+                # escape raw values
+                yield indent, saxutils.escape(item)
+   
+    def flatten (self) :
+        """
+            Render the tag and all content as a flattened series of indented lines.
+            
+            Empty tags collapse per default:
+
+            >>> list(Tag.build('foo').flatten())
+            [(0, u'<foo />')]
+            >>> list(Tag.build('bar', id=5).flatten())
+            [(0, u'<bar id="5" />')]
+
+            Values are indented inside the start tag:
+
+            >>> list(Tag.build('foo', 'bar', a=5).flatten())
+            [(0, u'<foo a="5">'), (1, u'bar'), (0, u'</foo>')]
+            
+            Nested tags are further indented:
+
+            >>> list(Tag.build('1', '1.1', Tag.build('1.2', '1.2.1'), '1.3', a=5).flatten())
+            [(0, u'<1 a="5">'), (1, u'1.1'), (1, u'<1.2>'), (2, u'1.2.1'), (1, u'</1.2>'), (1, u'1.3'), (0, u'</1>')]
+
+            Empty tags are rendered with a separate closing tag on the same line, if desired:
+
+            >>> list(Tag.build('foo', _selfclosing=False).flatten())
+            [(0, u'<foo></foo>')]
+            >>> list(Tag.build('foo', src='asdf', _selfclosing=False).flatten())
+            [(0, u'<foo src="asdf"></foo>')]
+
+            Tags that are declared as whitespace-sensitive are collapsed onto the same line:
+
+            >>> list(Tag.build('foo', _whitespace_sensitive=True).flatten())
+            [(0, u'<foo />')]
+            >>> list(Tag.build('foo', _whitespace_sensitive=True, _selfclosing=False).flatten())
+            [(0, u'<foo></foo>')]
+            >>> list(Tag.build('foo', 'bar', _whitespace_sensitive=True).flatten())
+            [(0, u'<foo>bar</foo>')]
+            >>> list(Tag.build('foo', 'bar\\nasdf\\tx', _whitespace_sensitive=True).flatten())
+            [(0, u'<foo>bar\\nasdf\\tx</foo>')]
+            >>> list(Tag.build('foo', 'bar', Tag.build('quux', 'asdf'), 'asdf', _whitespace_sensitive=True).flatten())
+            [(0, u'<foo>bar<quux>asdf</quux>asdf</foo>')]
+
+            Embedded HTML given as string values is escaped:
+
+            >>> list(Tag.build('foo', '<asdf>'))
+            [u'<foo>', u'\\t&lt;asdf&gt;', u'</foo>']
+
+            Embedded quotes in attribute values are esacaped:
+
+            >>> list(Tag.build('foo', style='ok;" onload="...'))
+            [u'<foo style=\\'ok;" onload="...\\' />']
+            >>> list(Tag.build('foo', style='ok;\\'" onload=..."\\''))
+            [u'<foo style="ok;\\'&quot; onload=...&quot;\\'" />']
+        """
+
+        # optional attr spec
+        if self.attrs :
+            attrs = " " + self.render_attrs()
+
+        else :
+            attrs = ""
+
+        if not self.contents and self.selfclosing is False :
+            # empty tag, but don't use the self-closing syntax..
+            yield 0, u"<%s%s></%s>" % (self.name, attrs, self.name)
+
+        elif not self.contents  :
+            # self-closing xml tag
+            # do note that this is invalid HTML, and the space before the / is relevant for parsing it as HTML
+            yield 0, u"<%s%s />" % (self.name, attrs)
+
+        elif self.whitespace_sensitive :
+            # join together each line for each child, discarding the indent
+            content = u''.join(line for indent, line in self.flatten_items())
+
+            # render full tag on a single line
+            yield 0, u"<%s%s>%s</%s>" % (self.name, attrs, content, self.name)
+
+        else :
+            # start tag
+            yield 0, u"<%s%s>" % (self.name, attrs)
+
+            # contents, indented one level below the start tag
+            for indent, line in self.flatten_items(indent=1) :
+                yield indent, line
+
+            # close tag
+            yield 0, u"</%s>" % (self.name, )
+
+    def __repr__ (self) :
+        return 'tag(%s)' % ', '.join(
+            [
+                repr(self.name)
+            ] + [
+                repr(c) for c in self.contents
+            ] + [
+                '%s=%r' % (name, value) for name, value in self.attrs.iteritems()
+            ]
+        )
+
+# factory function for Tag
+tag = Tag.build
+
+
+class Document (Renderable) :
+    """
+        A full XHTML 1.0 document with optional XML header, doctype, html[@xmlns].
+
+        >>> list(Document(tags.html('...')))
+        [u'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">', u'<html xmlns="http://www.w3.org/1999/xhtml">', u'\\t...', u'</html>']
+    """
+
+    def __init__ (self, root,
+        doctype='html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"',
+        html_xmlns='http://www.w3.org/1999/xhtml',
+        xml_version=None, xml_encoding=None, 
+    ) :
+        # add xmlns attr to root node
+        self.root = root(xmlns=html_xmlns)
+
+        # store
+        self.doctype = doctype
+        self.xml_declaration = {}
+
+        if xml_version :
+            self.xml_declaration['version'] = xml_version
+
+        if xml_encoding :
+            self.xml_declaration['encoding'] = xml_encoding
+
+    def flatten (self) :
+        """
+            Return the header lines along with the normally formatted <html> tag
+        """
+        
+        if self.xml_declaration :
+            yield 0, u'<?xml %s ?>' % (' '.join('%s="%s"' % kv for kv in self.xml_declaration.iteritems()))
+
+        if self.doctype :
+            yield 0, u'<!DOCTYPE %s>' % (self.doctype)
+
+        # <html>
+        for indent, line in self.root.flatten() :
+            yield indent, line
+
+class TagFactory (object) :
+    """
+        Build Tags with names give as attribute names
+        
+        >>> list(TagFactory().a(href='#')('Yay'))
+        [u'<a href="#">', u'\\tYay', u'</a>']
+
+        >>> list(TagFactory().raw("><"))
+        [u'><']
+    """
+
+    # full XHTML document
+    document = Document
+    
+    def __getattr__ (self, name) :
+        """
+            Get a Tag object with the given name, but no contents
+
+            >>> TagFactory().a
+            tag('a')
+        """
+
+        return Tag(name, [], {})
+
+    def __call__ (self, value) :
+        """
+            Raw HTML.
+        """
+
+        return Text(value)
+
+# static instance
+tags = TagFactory()
+
+# testing
+if __name__ == '__main__' :
+    import doctest
+
+    doctest.testmod()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/db.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,60 @@
+import sqlalchemy
+from sqlalchemy import *
+
+import logging; log = logging.getLogger('pvl.verkko.db')
+
+DATABASE = 'sqlite:///var/verkko.db'
+
+# schema
+metadata = MetaData()
+
+dhcp_hosts = Table('dhcp_hosts', metadata,
+    Column('rowid',         Integer, primary_key=True),
+
+    # unique
+    Column('ip',            String),
+    Column('mac',           String),
+    Column('gw',            String),
+    
+    # updated
+    Column('name',          String),
+    Column('first_seen',    DateTime),
+    Column('last_seen',     DateTime),
+)
+
+# for ORM models
+from sqlalchemy.orm import mapper, sessionmaker
+
+Session = sessionmaker()
+
+
+class Database (object) :
+    """
+        Our underlying database.
+    """
+    
+    # XXX: alias Tables in?
+    dhcp_hosts = dhcp_hosts
+
+    def __init__ (self, database=DATABASE) :
+        self.engine = create_engine(database, 
+            echo    = (log.isEnabledFor(logging.DEBUG)),
+        )
+        self.session = Session(bind=self.engine)
+
+    # ORM
+    def query (self, cls) :
+        return self.session.query(cls)
+    
+    # SQL
+    def select (self, query) :
+        return self.engine.connect().execute(query)
+
+    def get (self, query) :
+        """
+            Fetch a single row.
+            
+            XXX: returning None if not found
+        """
+
+        return self.select(query).fetchone()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/hosts.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,197 @@
+from pvl.verkko import db, web
+
+from pvl.html import tags as html
+
+import socket # dns
+
+import logging; log = logging.getLogger('pvl.verkko.hosts')
+
+class Host (object) :
+    DATE_FMT = '%Y%m%d'
+
+    def __init__ (self, ip, mac, name=None) :
+        self.ip = ip
+        self.mac = mac
+        self.name = name
+   
+    def render_mac (self) :
+        if not self.mac :
+            return None
+
+        elif len(self.mac) > (6 * 2 + 5) :
+            return u'???'
+
+        else :
+            return unicode(self.mac)
+
+    def render_name (self) :
+        if self.name :
+            return self.name.decode('ascii', 'replace')
+        else :
+            return None
+
+    def when (self) :
+        return '{frm} - {to}'.format(
+                frm = self.first_seen.strftime(self.DATE_FMT),
+                to  = self.last_seen.strftime(self.DATE_FMT),
+        )
+
+    def dns (self) :
+        """
+            Reverse-DNS lookup.
+        """
+
+        if not self.ip :
+            return None
+        
+        sockaddrs = set(sockaddr for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(self.ip, 0, 0, 0, 0, socket.AI_NUMERICHOST))
+
+        for sockaddr in sockaddrs :
+            try :
+                host, port = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD)
+            except socket.gaierror :
+                continue
+
+            return host
+
+    def __unicode__ (self) :
+        return u"{host.ip} ({host.mac})".format(host=self)
+
+db.mapper(Host, db.dhcp_hosts, properties=dict(
+    id      = db.dhcp_hosts.c.rowid,
+    #_mac    = db.dhcp_hosts.c.mac,
+    #_name   = db.dhcp_hosts.c.name,
+))
+
+HOST_SORT = {
+    'id':   Host.id,
+    'ip':   Host.ip,
+    'mac':  Host.mac,
+    'name': Host.name,
+    'seen': Host.last_seen,
+    None:   Host.last_seen.desc(),
+}
+
+def render_hosts (hosts, title=None) :
+    COLS = (
+        #title          sort
+        ('#',           None),
+        ('IP',          'ip'),
+        ('MAC',         'mac'),
+        ('Hostname',    'name'),
+        ('Seen',        'seen'),
+    )
+
+    return html.table(
+        html.caption(title) if title else None,
+        html.thead(
+            html.tr(
+                html.th(
+                    html.a(href='?sort={name}'.format(name=sort))(title) if sort else (title)
+                ) for title, sort in COLS
+            )
+        ),
+        html.tbody(
+            html.tr(class_=('alternate' if i % 2 else None), id=host.id)(
+                html.td(class_='id')(
+                    html.a(href='/hosts/{host.id}'.format(host=host))(
+                        '#' #host['rowid'])
+                    )
+                ),
+                html.td(class_='ip')(
+                    html.a(href='/hosts/ip/{host.ip}'.format(host=host))(host.ip)
+                ),
+                html.td(class_='mac')(
+                    html.a(href='/hosts/mac/{host.mac}'.format(host=host))(
+                        host.render_mac()
+                    )
+                ),
+                html.td(host.render_name()),
+                html.td(host.when()),
+            ) for i, host in enumerate(hosts)
+        )
+    )
+
+def render_host (host, hosts) :
+    title = 'DHCP Host: {host}'.format(host=host)
+    
+    attrs = (
+            ('IP',          host.ip),
+            ('MAC',         host.mac),
+            ('Hostname',    host.name),
+            ('DNS',         host.dns()),
+    )
+
+    return (
+        html.h2('Host'),
+        html.dl(
+            (html.dt(title), html.dd(value)) for title, value in attrs
+        ),
+
+        html.h2('Related'),
+        render_hosts(hosts),
+
+        html.a(href='/')(html('&laquo;'), 'Back'),
+    )
+
+from werkzeug.wrappers import Response
+
+def respond (request, db, path) :
+    """
+        Handle request
+    """
+
+    hosts = db.query(Host)
+
+    # sort ?
+    sort = request.args.get('sort')
+    sort = HOST_SORT[sort]
+    log.debug("sort: %s", sort)
+
+    hosts = hosts.order_by(sort)
+    
+    # index
+    if not path :
+        title = "DHCP Hosts"
+        html = render_hosts(hosts)
+    
+    # id
+    elif len(path) == 1 :
+        id, = path
+        id = int(id)
+
+        host = hosts.get(id)
+        hosts = hosts.filter((Host.ip == host.ip) | (Host.mac == host.mac))
+        
+        # render
+        title = "DHCP Host: {host}".format(host=unicode(host))
+        html = render_host(host, hosts)
+    
+    # query
+    elif len(path) == 2 :
+        attr, value = path
+        
+        # fake host
+        host = { 'ip': None, 'mac': None, 'name': None }
+        host[attr] = value
+
+        host = Host(**host)
+
+        # query
+        attr = HOST_SORT[attr]
+        log.debug("%s == %s", attr, value)
+
+        hosts = hosts.filter(attr == value)
+        
+        # render
+        title = "DHCP Hosts: {value}".format(value=value)
+        html = render_host(host, hosts)
+
+    else :
+        return Response("Not Found", status=404)
+
+    # render
+    html = web.render_layout(title, html)
+
+    return Response(html, content_type='text/html; charset=UTF-8')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/web.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,23 @@
+# view
+from pvl.html import tags as html
+
+def render_layout (title, content) :
+    css = [
+        "/static/layout.css", 
+        "/static/style.css", 
+    ]
+
+    return html.document(html.html(
+        html.head(
+            html.title(title),
+            (
+                html.link(rel='Stylesheet', type="text/css", href=src) for src in css
+            ),
+        ),
+        html.body(
+            html.h1(title),
+            content 
+        )
+    ))
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/wsgi.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,32 @@
+import werkzeug
+from werkzeug.wrappers import Request, Response
+from werkzeug.utils import redirect
+
+import logging; log = logging.getLogger('pvl.verkko.wsgi')
+
+from pvl.verkko import db as database, hosts
+
+@Request.application
+def application (request) :
+    db = database.Database()
+    
+    # path?
+    path = request.path.strip('/')
+    
+    if path :
+        path = path.split('/')
+    else :
+        path = []
+
+    log.debug("path: %s", path)
+    
+    # respond
+    if not path :
+        return redirect('/hosts')
+
+    elif path[0] == 'hosts' :
+        return hosts.respond(request, db, path[1:])
+
+    else :
+        return Response("Not Found", status=404)
+    
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/style.css	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,125 @@
+/*
+ * General
+ */
+a
+{
+    color: inherit;
+}
+
+/*
+ * 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;
+}
+
+/* Rows */
+tr
+{
+    background-color: #e0e0e0;
+}
+
+tr.alternate
+{
+    background-color: #c8c8c8;
+}
+
+/* Link to tr with URL #foo fragment */
+tr:target
+{
+    background-color: #F5F3B8;
+}
+
+td, th
+{
+    border: thin solid #ffffff;
+
+    padding: 0.25em 1em;
+}
+
+td a,
+th a
+{
+    display: block;
+}
+
+td.row-header
+{
+    text-align: right;
+    font-weight: bold;
+}
+
+/*
+ * Details
+ */
+div.info {
+    width: 80%;
+
+    border: 1px solid #aaa;
+
+    padding: 1em;
+    margin: 1em;
+}
+
+dl {
+    width: 80%;
+    
+    margin: 1em;
+}
+
+dl dt {
+    display: block;
+    clear: both;
+
+    font-weight: bold;
+    
+    margin-top: 0.5em;
+}
+
+dl dd {
+
+}
+
+/*
+ * Text
+ */
+
+.id
+{
+    font-weight: bold;
+    text-align: right;
+}
+
+.ip,
+.mac
+{
+    font-family: monospace;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test.py	Wed Oct 10 21:39:54 2012 +0300
@@ -0,0 +1,66 @@
+#!/usr/bin/python
+
+from werkzeug.serving import run_simple
+
+from pvl import __version__
+import pvl.args
+import pvl.verkko.wsgi
+
+import optparse
+import logging; log = logging.getLogger('main')
+
+def parse_argv (argv, doc = __doc__) :
+    """
+        Parse command-line argv, returning (options, args).
+
+        Use pvl.ldap.args.apply() to get the LDAPClient
+    """
+
+    prog = argv.pop(0)
+    args = argv
+
+    # optparse
+    parser = optparse.OptionParser(
+        prog        = prog,
+        usage       = '%prog: [options] [<user> [...]]',
+        version     = __version__,
+        description = doc,
+    )
+
+    # common
+    parser.add_option_group(pvl.args.parser(parser))
+
+    # parse
+    options, args = parser.parse_args(args)
+
+    # apply
+    pvl.args.apply(options)
+
+    return options, args
+
+def main (argv) :
+    """
+        Manage LDAP user dirs/symlinks/quotas on system.
+    """
+
+    # parse cmdline
+    options, args = parse_argv(argv, doc=__doc__)
+
+    # app
+    application = pvl.verkko.wsgi.application
+
+    # wsgi wrapper
+    run_simple('0.0.0.0', 8080, application,
+            #use_reloader    = True, 
+            use_debugger    = (options.loglevel == logging.DEBUG),
+            static_files    = {
+                '/static':      'static',
+            },
+    )
+
+if __name__ == '__main__' :
+    import sys
+
+    sys.exit(main(sys.argv))
+
+