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 55305a934bac created 10 years 4 months ago.
By Nathan Adams, Fixing bug where password would not be hashed in database if user updated password

Archive Download this file

Branches

Tags

Page rendered in 1.53899s using 11 queries.