srchub-old

srchub-old Mercurial Source Tree


Root/pluf/src/Pluf/Paginator.php

<?php
/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/*
# ***** BEGIN LICENSE BLOCK *****
# This file is part of Plume Framework, a simple PHP Application Framework.
# Copyright (C) 2001-2007 Loic d'Anterroches and contributors.
#
# Plume Framework is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Plume Framework is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
#
# ***** END LICENSE BLOCK ***** */

Pluf::loadFunction('Pluf_HTTP_URL_urlForView');

/**
 * Paginator to display a list of model items.
 *
 * The paginator has several methods to render the complete table in
 * one call or to give more freedom to the end user in the table by
 * doing it item by item.
 *
 * An example of usage is as follow:
 *
 * <code>
 * $model = new Pluf_Permission();
 * $pag = new Pluf_Paginator($model);
 * // Set the action to the page listing the permissions
 * $pag->action = 'view_name'; 
 * // Get the paginator parameters from the request
 * $pag->setFromRequest($request);
 * print $pag->render();
 * </code>
 *
 * This example shows the fast way. That means that the items will be
 * shown according to the default values of the paginator or with the
 * details given in the admin definition of the model.
 */
class Pluf_Paginator
{
    /**
     * The model being paginated.
     */
    protected $model;

    /**
     * The items being paginated. If no model is given when creating
     * the paginator, it will use the items to list them.
     */
    public $items = null;

    /**
     * Extra property/value for the items.
     *
     * This can be practical if you want some values for the edit
     * action which are not available in the model data.
     */
    public $item_extra_props = array();

    /**
     * The fields being shown.
     *
     * If no fields are given, the __toString representation of the
     * item will be used to show the item.
     *
     * If an item in the list_display is an array the format is the
     * following:
     * array('field', 'Custom_Function_ToApply', 'custom header name')
     *
     * The signature of the the Custom_Function_ToApply is:
     * string = Custom_Function_ToApply('field', $item);
     *
     * By using for example 'id' as field with a custom header name
     * you can create new columns in the table.
     */
    protected $list_display = array();

    /**
     * List filter.
     *
     * Allow the generation of filtering options for the list. If you
     * provide a list of fields having a "choices" option, you will be
     * able to filter on the choice values.
     */
    public $list_filters = array();
    
    /**
     * Extra classes that will be applied to the td of each cell of
     * each column.
     *
     * If you have 3 columns and put array('one', '', 'foo') all the
     * td of the first column will have the class 'one' set and the
     * tds of the last column will have the 'foo' class set.
     */
    public $extra_classes = array();

    /**
     * The fields being searched.
     */
    protected $search_fields = array();

    /**
     * The where clause from the search.
     */
    protected $where_clause = null;

    /**
     * The forced where clause on top of the search.
     */
    public $forced_where = null;

    /**
     * View of the model to be used.
     */
    public $model_view = null;

    /**
     * Maximum number of items per page.
     */
    public $items_per_page = 50;

    /**
     * Current page.
     */
    public $current_page = 1;

    /**
     * Number of pages.
     */
    public $page_number = 1;

    /**
     * Search string.
     */
    public $search_string = '';

    /**
     * Text to display when no results are found.
     */
    public $no_results_text = 'No items found';

    /**
     * Which fields of the model can be used to sort the dataset. To
     * be useable these fields must be in the $list_display so that
     * the sort links can be shown for the user to click on them and
     * sort the list.
     */
    public $sort_fields = array();

    /**
     * Current sort order. An array with first value the field and
     * second the order of the sort.
     */
    public $sort_order = array();

    /**
     * Keys where the sort is reversed. Let say, you have a column
     * using a timestamp but displaying the information as an age. If
     * you sort "ASC" you espect to get the youngest first, but as the
     * timestamp is used, you get the oldest. But the key here and the
     * sort will be reverted.
     */
    public $sort_reverse_order = array();
    
    /**
     *
     * Do not add the little sort links but directly make the title of
     * the column a link to sort.
     */
    public $sort_link_title = false;
    
    /**
     * Edit action.
     *
     */
    public $edit_action  = '';

    /**
     * Action for search/next/previous.
     */
    public $action = '';

    /**
     * Id/class of the generated table.
     */
    public $id = '';
    public $class = '';

    /**
     * Extra parameters for the modification function call.
     */
    public $extra = null;

    /**
     * Summary for the table.
     */
    public $summary = '';

    /**
     * Total number of items. 
     *
     * Available only after the rendering of the paginator.
     */
    public $nb_items = 0;

    protected $active_list_filter = array();

    /**
     * Maximum number of pages to be displayed
     *
     * Instead of showing by default unlimited number of pages,
     * limit to this value.
     * 0 is unlimited (default).
     *
     * Ex: max_number_pages = 3 will produce
     * Prev 1 ... 498 499 500 ... 1678 Next
     */
    public $max_number_pages = 0;
    public $max_number_pages_separator = '...';

    public $custom_max_items = false;

    /**
     * First, Previous, Next and Last page display
     * Default First = 1, Last = last page num
     * Prev and Next are initialized to null. In the footer() we will
     * set Prev = __('Prev') and Next = __('Next') if not set
     * Last has to be set during render if not set so that we know
     * the number of pages
     */
    public $symbol_first = '1';
    public $symbol_last = null;
    public $symbol_prev = null;
    public $symbol_next = null;

    /**
     * Construct the paginator for a model.
     *
     * @param object Model to paginate (null).
     * @param array List of the headers to show (array()).
     * @param array List of the fields to search (array()).
     */
    function __construct($model=null, $list_display=array(), 
                         $search_fields=array())
    {
        $this->model = $model;
        $this->configure($list_display, $search_fields);
    }

    /**
     * Configure the paginator.
     *
     * @param array List of the headers to show.
     * @param array List of the fields to search (array())
     * @param array List of the fields to sort the data set (array())
     */
    function configure($list_display, $search_fields=array(), $sort_fields=array())
    {
        if (is_array($list_display)) {
            $this->list_display = array();
            foreach ($list_display as $key=>$col) {
                if (!is_array($col) && !is_null($this->model) && isset($this->model->_a['cols'][$col]['verbose'])) {
                    $this->list_display[$col] = $this->model->_a['cols'][$col]['verbose'];
                } elseif (!is_array($col)) {
                    if (is_numeric($key)) {
                        $this->list_display[$col] = $col;
                    } else {
                        $this->list_display[$key] = $col;
                    }
                } else {
                    if (count($col) == 2 
                        && !is_null($this->model)
                        && isset($this->model->_a['cols'][$col[0]]['verbose'])) {
                        $col[2] = $this->model->_a['cols'][$col[0]]['verbose'];
                    } elseif (count($col) == 2 ) {
                        $col[2] = $col[0];
                    }
                    $this->list_display[] = $col;
                }
            }
        }
	//print_r($this->list_display);
        if (is_array($search_fields)) {
            $this->search_fields = $search_fields;
        }
        if (is_array($sort_fields)) {
            $this->sort_fields = $sort_fields;
        }
    }

    /**
     * Set the parameters from the request.
     *
     * Possible parameters are:
     * _px_q : Query string to search.
     * _px_p : Current page.
     * _px_sk : Sort key.
     * _px_so : Sort order.
     * _px_fk : Filter key.
     * _px_fv : Filter value.
     *
     * @param Pluf_HTTP_Request The request
     */
    function setFromRequest($request)
    {
        if (isset($request->REQUEST['_px_q'])) {
            $this->search_string = $request->REQUEST['_px_q'];
        }
        if (isset($request->REQUEST['_px_p'])) {
            $this->current_page = (int) $request->REQUEST['_px_p'];
            $this->current_page = max(1, $this->current_page);
        }
        if (isset($request->REQUEST['_px_sk']) 
            and in_array($request->REQUEST['_px_sk'], $this->sort_fields)) {
            $this->sort_order[0] = $request->REQUEST['_px_sk'];
            $this->sort_order[1] = 'ASC';
            if (isset($request->REQUEST['_px_so']) 
                and ($request->REQUEST['_px_so'] == 'd')) {
                $this->sort_order[1] = 'DESC';
            }
        }
        if (isset($request->REQUEST['_px_fk']) 
            and in_array($request->REQUEST['_px_fk'], $this->list_filters)
            and isset($request->REQUEST['_px_fv'])) {
            // We add a forced where query
            $sql = new Pluf_SQL($request->REQUEST['_px_fk'].'=%s',
                                $request->REQUEST['_px_fv']);
            if (!is_null($this->forced_where)) {
                $this->forced_where->SAnd($sql);
            } else {
                $this->forced_where = $sql;
            }
            $this->active_list_filter = array($request->REQUEST['_px_fk'],
                                              $request->REQUEST['_px_fv']);
        }
    }
        

    /**
     * Render the complete table.
     *
     * When an id is provided, the generated table receive this id.
     *
     * @param string Table id ('')
     */
    function render($id='')
    {
        $this->id = $id;
        $_sum = '';
        if (strlen($this->summary)) {
            $_sum = ' summary="'.htmlspecialchars($this->summary).'"';
        }
        $out = '<table'.$_sum.(($this->class) ? ' class="'.$this->class.'"' : '').(($this->id) ? ' id="'.$this->id.'">' : '>')."\n";
        $out .= '<thead>'."\n";
        $out .= $this->searchField();
        $out .= $this->colHeaders();
        $out .= '</thead>'."\n";
        // Opt: Generate the footer of the table with the next/previous links
        $out .= $this->footer();
        // Generate the body of the table with the items
        $out .= $this->body();
        $out .= '</table>'."\n";
        return new Pluf_Template_SafeString($out, true);
    }

    /**
     * Render as array.
     *
     * An array rendering do not honor the limits, that is, all the
     * items are returned. Also, the output is not formatted values
     * from the db are directly returned. This is perfect to then use
     * the values in a JSON response.
     *
     * @return Array.
     */
    function render_array()
    {
        if (count($this->sort_order) != 2) {
            $order = null;
        } else {
            $s = $this->sort_order[1];
            if (in_array($this->sort_order[0], $this->sort_reverse_order)) {
                $s = ($s == 'ASC') ? 'DESC' : 'ASC';
            }
            $order = $this->sort_order[0].' '.$s;
        }
        if (!is_null($this->model)) {
            $items = $this->model->getList(array('view' => $this->model_view, 
                                                 'filter' => $this->filter(), 
                                                 'order' => $order, 
                                                 ));
        } else {
            $items = $this->items;
        }
        $out = array();
        foreach ($items as $item) {
            $idata = array();
            if (!empty($this->list_display)) {
                $i = 0;
                foreach ($this->list_display as $key=>$col) {
                    if (!is_array($col)) {
                        $idata[$key] = $item->$key;
                    } else {
                        $_col = $col[0];
                        $idata[$col[0]] = $item->$_col;
                    }
                }
            } else {
                $idata = $item->id;
            }
            $out[] = $idata;
        }
        return $out;
    }

    /**
     * Generate the footer of the table.
     */
    function footer()
    {
        // depending on the search string, the result set can be
        // limited. So we need first to count the total number of
        // corresponding items. Then get a slice of them in the
        // generation of the body.
        if (!is_null($this->model)) {
            $nb_items = $this->model->getCount(array('view' => $this->model_view,  'filter' => $this->filter()));
        } else {
            $nb_items = $this->items->count();
        }
        $this->nb_items = $nb_items;
        if ($nb_items <= $this->items_per_page) {
            return '';
        }
        $this->page_number = ceil($nb_items / $this->items_per_page);
        if ($this->current_page > $this->page_number) {
            $this->current_page = 1;
        }
        $params = array();
        if (!empty($this->search_fields)) {
            $params['_px_q'] = $this->search_string;
            $params['_px_p'] = $this->current_page;
        }
        if (!empty($this->sort_order)) {
            $params['_px_sk'] = $this->sort_order[0];
            $params['_px_so'] = ($this->sort_order[1] == 'ASC') ? 'a' : 'd';
        }
        // Add the filtering
        if (!empty($this->active_list_filter)) {
            $params['_px_fk'] = $this->active_list_filter[0];
            $params['_px_fv'] = $this->active_list_filter[1];
        }
        $out = '<tfoot><tr><th colspan="'.count($this->list_display).'">'."\n";
        if ($this->current_page != 1) {
            $params['_px_p'] = $this->current_page - 1;
            $url = $this->getUrl($params);
            $this->symbol_prev = ($this->symbol_prev ==null) ?__('Prev') : $this->symbol_prev;
            $out .= '<a href="'.$url.'">'.$this->symbol_prev.'</a> ';
        }
        // Always display the link to Page#1
        $i=1;
        $params['_px_p'] = $i;
        $class = ($i == $this->current_page) ? ' class="px-current-page"' : '';
        $url = $this->getUrl($params);
        $out .= '<a'.$class.' href="'.$url.'">'.$this->symbol_first.'</a> ';
        // Display the number of pages given $this->max_number_pages
        if ($this->max_number_pages > 0) {
            $nb_pa = floor($this->max_number_pages/2);
            $imin = $this->current_page - $nb_pa;
            $imax = $this->current_page + $nb_pa;
            // We put the separator if $imin is at leat greater than 2
            if ($imin > 2) $out .= ' '.$this->max_number_pages_separator.' ';
            if ($imin <= 1) $imin=2;
            if ($imax >= $this->page_number) $imax = $this->page_number - 1;
        } else {
            $imin = 2;
            $imax = $this->page_number - 1;
        }
        for ($i=$imin; $i<=$imax; $i++) {
            $params['_px_p'] = $i;
            $class = ($i == $this->current_page) ? ' class="px-current-page"' : '';
            $url = $this->getUrl($params);
            $out .= '<a'.$class.' href="'.$url.'">'.$i.'</a> ';
        }
        if (($this->max_number_pages > 0) && $imax < ($this->page_number - 1)) {
            $out .= ' '.$this->max_number_pages_separator.' ';
        }
        // Always display the link to last Page
        $i = $this->page_number;
        $params['_px_p'] = $i;
        $class = ($i == $this->current_page) ? ' class="px-current-page"' : '';
        $url = $this->getUrl($params);
        if ($this->symbol_last == null) $this->symbol_last=$i;
        $out .= '<a'.$class.' href="'.$url.'">'.$this->symbol_last.'</a> ';
        if ($this->current_page != $this->page_number) {
            $params['_px_p'] = $this->current_page + 1;
            $url = $this->getUrl($params);
            $this->symbol_next = ($this->symbol_next == null) ? __('Next') : $this->symbol_next;
            $out .= '<a href="'.$url.'">'.$this->symbol_next.'</a> ';
        }
        $out .= '</th></tr></tfoot>'."\n";
        return $out;
    }

    /**
     * Generate the body of the list.
     */
    function body()
    {
        $st = ($this->current_page-1) * $this->items_per_page;
        if (count($this->sort_order) != 2) {
            $order = null;
        } else {
            $s = $this->sort_order[1];
            if (in_array($this->sort_order[0], $this->sort_reverse_order)) {
                $s = ($s == 'ASC') ? 'DESC' : 'ASC';
            }
            $order = $this->sort_order[0].' '.$s;
        }
        if (!is_null($this->model)) {
            $items = $this->model->getList(array('view' => $this->model_view, 
                                                 'filter' => $this->filter(), 
                                                 'order' => $order, 
                                                 'start' => $st, 
                                                 'nb' => $this->items_per_page));
        } else {
            $items = $this->items;
        }
        $out = '';
        $total = $items->count();
        $count = 1;
        foreach ($items as $item) {
            $item->_paginator_count = $count;
            $item->_paginator_total_page = $total;
            foreach ($this->item_extra_props as $key=>$val) {
                $item->$key = $val;
            }
            $out .= $this->bodyLine($item);
            $count++;
        }
        if (strlen($out) == 0) {
            $out = '<tr><td colspan="'
                .count($this->list_display).'">'.$this->no_results_text
                .'</td></tr>'."\n";
        }
        return '<tbody>'.$out.'</tbody>'."\n";
    }
    
    /**
     * Generate a standard "line" of the body
     */
    function bodyLine($item)
    {
        $out = '<tr>';
        if (!empty($this->list_display)) {
            $i = 0;
            foreach ($this->list_display as $key=>$col) {
                $text = '';
                if (!is_array($col)) {
                    $text = Pluf_esc($item->$key);
                } else {
                    if (is_null($this->extra)) {
                        $text = $col[1]($col[0], $item);
                    } else {
                        $text = $col[1]($col[0], $item, $this->extra);
                    }
                }
                if ($i == 0) {
                    $text = $this->getEditAction($text, $item);
                }
                $class = (isset($this->extra_classes[$i]) and $this->extra_classes[$i] != '') ? ' class="'.$this->extra_classes[$i].'"' : '';
                $out.='<td'.$class.'>'.$text.'</td>';
                $i++;
            }
        } else {
            $out.='<td>'.$this->getEditAction(Pluf_esc($item), $item).'</td>';
        }
        $out .= '</tr>'."\n";
        return $out;
    }

    /**
     * Get the edit action.
     *
     * @param string Text to put in the action. 
     *               No escaping of the text is performed.
     * @param object Model for the action.
     * @return string Ready to use string.
     */
    function getEditAction($text, $item)
    {
        $edit_action = $this->edit_action;
        if (!empty($edit_action)) {
            if (!is_array($edit_action)) {
                $params = array($edit_action, $item->id);
            } else {
                $params = array(array_shift($edit_action));
                foreach ($edit_action as $field) {
                    $params[] = $item->$field;
                }
            }
            $view = array_shift($params);
            $url = Pluf_HTTP_URL_urlForView($view, $params);
            return  '<a href="'.$url.'">'.$text.'</a>';
        } else {
            return $text;
        }
    }

    /**
     * Generate the where clause.
     *
     * @return string The ready to use where clause.
     */
    function filter()
    {
        if (strlen($this->where_clause) > 0) {
            return $this->where_clause;
        }
        if (!is_null($this->forced_where) 
            or (strlen($this->search_string) > 0
                && !empty($this->search_fields))) {
            $lastsql = new Pluf_SQL();
            $keywords = $lastsql->keywords($this->search_string);
            foreach ($keywords as $key) {
                $sql = new Pluf_SQL();
                foreach ($this->search_fields as $field) {
                    $sqlor = new Pluf_SQL();
                    $sqlor->Q($field.' LIKE %s', '%'.$key.'%');
                    $sql->SOr($sqlor);
                }
                $lastsql->SAnd($sql);
            }
            if (!is_null($this->forced_where)) {
                $lastsql->SAnd($this->forced_where);
            }
            $this->where_clause = $lastsql->gen();
            if (strlen($this->where_clause) == 0) 
                $this->where_clause = null;
        }            
        return $this->where_clause;
    }

    /**
     * Generate the column headers for the table.
     */
    function colHeaders()
    {
        if (empty($this->list_display)) {
            return '<tr><th>'.__('Name').'</th></tr>'."\n";
        } else {
            $out = '<tr>';
            foreach ($this->list_display as $key=>$col) {
                if (is_array($col)) {
                    $field = $col[0];
                    $name = $col[2];
                    Pluf::loadFunction($col[1]);
                } else {
                    $name = $col;
                    $field = $key;
                }
		//print_r($this->list_display);
                if (!$this->sort_link_title) {
                    $out .= '<th><span class="px-header-title">'.Pluf_esc(ucfirst($name)).'</span>'.$this->headerSortLinks($field).'</th>';
                } else {
                    $out .= '<th><span class="px-header-title">'.$this->headerSortLinks($field, Pluf_esc(ucfirst($name))).'</span></th>';
                }
            }
            $out .= '</tr>'."\n";
            return $out;
        }
    }

    /**
     * Generate the little text on the header to allow sorting if
     * available.
     *
     * If the title is set, the link is directly made on the title.
     *
     * @param string Name of the field
     * @param string Title ('')
     * @return string HTML fragment with the links to 
     *                sort ASC/DESC on this field.
     */
    function headerSortLinks($field, $title='')
    {
        if (!in_array($field, $this->sort_fields)) {
            return $title;
        }
        $params = array();
        if (!empty($this->search_fields)) {
            $params['_px_q'] = $this->search_string;
        }
        if (!empty($this->active_list_filter)) {
            $params['_px_fk'] = $this->active_list_filter[0];
            $params['_px_fv'] = $this->active_list_filter[1];
        }
        $params['_px_sk'] = $field;
        $out = '<span class="px-sort">'.__('Sort').' %s/%s</span>';
        $params['_px_so'] = 'a';
        $aurl = $this->getUrl($params);
        $asc = '<a href="'.$aurl.'" >'.__('asc').'</a>';
        $params['_px_so'] = 'd';
        $durl = $this->getUrl($params);
        $desc = '<a href="'.$durl.'" >'.__('desc').'</a>';
        if (strlen($title)) {
            if (count($this->sort_order) == 2
                and $this->sort_order[0] == $field 
                and $this->sort_order[1] == 'ASC') {
                return '<a href="'.$durl.'" >'.$title.'</a>';
            }
            return '<a href="'.$aurl.'" >'.$title.'</a>';
        }
        return sprintf($out, $asc, $desc);
    }

    /**
     * Get the search field XHTML.
     */
    function searchField()
    {
        if (empty($this->search_fields)) {
            return '';
        }
        $url = $this->getUrl();
        return '<tr><th class="px-table-search" colspan="'
            .count($this->list_display).'">'
            .'<form method="get" action="'.$url.'">'
            .'<label for="px-q">'.__('Filter the list:').'</label> '
            .'<input type="text" name="_px_q" id="px-q" size="30"'
            .' value="'.htmlspecialchars($this->search_string).'" />'
            .'<input type="submit" name="submit" value="'.__('Filter').'" />'
            .'</form></th></tr>'."\n";
        
    }

    /**
     * Using $this->action and the $get_params array, generate the URL
     * with the data.
     *
     * @param array Get parameters (array()).
     * @param bool Encoded to be put in href="" (true).
     * @return string Url.
     */
    function getUrl($get_params=array(), $encoded=true)
    {
        // Default values
        $params = array();
        $by_name = false;
        $view = '';
        if (is_array($this->action)) {
            $view = $this->action[0];
            if (isset($this->action[1])) {
                $params = $this->action[1];
            }
        } else {
            $view = $this->action;
        }
        return Pluf_HTTP_URL_urlForView($view, $params, $get_params, $encoded);
    }

    /**
     * Overloading of the get method.
     *
     * @param string Property to get
     */
    function __get($prop)
    {
        if ($prop == 'render') return $this->render();
        return $this->$prop;
    }

}

/**
 * Returns the string representation of an item.
 *
 * @param string Field (not used)
 * @param Object Item
 * @return string Representation of the item
 */
function Pluf_Paginator_ToString($field, $item)
{
    return Pluf_esc($item);
}

/**
 * Returns the item referenced as foreign key as a string.
 */
function Pluf_Paginator_FkToString($field, $item)
{
    $method = 'get_'.$field;
    $fk = $item->$method();
    return Pluf_esc($fk);
}

function Pluf_Paginator_DateYMDHMS($field, $item)
{
    Pluf::loadFunction('Pluf_Template_dateFormat');
    return Pluf_Template_dateFormat($item->$field, '%Y-%m-%d %H:%M:%S');
}

function Pluf_Paginator_DateYMDHM($field, $item)
{
    Pluf::loadFunction('Pluf_Template_dateFormat');
    return Pluf_Template_dateFormat($item->$field, '%Y-%m-%d %H:%M');
}

function Pluf_Paginator_DateYMD($field, $item)
{
    Pluf::loadFunction('Pluf_Template_dateFormat');
    return Pluf_Template_dateFormat($item->$field, '%Y-%m-%d');
}

function Pluf_Paginator_DisplayVal($field, $item)
{
    return $item->displayVal($field);
}

function Pluf_Paginator_DateAgo($field, $item)
{
    Pluf::loadFunction('Pluf_Date_Easy');
    Pluf::loadFunction('Pluf_Template_dateFormat');
    $date = Pluf_Template_dateFormat($item->$field, '%Y-%m-%d %H:%M:%S');
    return Pluf_Date_Easy($date, null, 2, __('now'));
}
Source at commit 4ca1e1c61211 created 11 years 4 months ago.
By "Nathan Adams ", Fixing newline issue

Archive Download this file

Branches

Tags

Page rendered in 1.11756s using 11 queries.