pvl/verkko/table.py
author Tero Marttila <terom@paivola.fi>
Sun, 22 Dec 2013 19:03:57 +0200
changeset 336 edaa5d0aa57d
parent 242 088d68412650
permissions -rw-r--r--
version 0.6.1: pvl.hosts forward/reverse delegation, and include= support
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("&laquo;&laquo; First"))
            yield html.a(href=self.tableurl(page=(page - 1), **opts))(html("&laquo; 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("&raquo; 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),
        )