pvl/verkko/table.py
changeset 180 e6bca452ce72
child 183 8fbaaf0564dc
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/table.py	Sat Jan 26 21:06:00 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("&laquo;&laquo; First"))
+            yield html.a(href=self.tableurl(page=(page - 1)))(html("&laquo; 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("&raquo; 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,
+        )
+