• Facebook
  • Twitter
  • Reddit
  • StumbleUpon
  • Digg
  • email

from django.conf import settings
from django.contrib.auth.models import SiteProfileNotAvailable
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import InvalidPage, Paginator
from django.http import Http404, HttpResponse
from django.shortcuts import render_to_response
from django.template.context import RequestContext
from django.template.defaultfilters import date, timesince
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
from django.db.models import Q
 
import StringIO
 
 
class Column(object):
    """
    A column in a data grid.
 
    The column is the primary component of the data grid. It is used to
    display not only the column header but the HTML for the cell as well.
 
    Columns can be tied to database fields and can be used for sorting.
    Not all columns have to allow for this, though.
 
    Columns can have an image, text, or both in the column header. The
    contents of the cells can be instructed to link to the object on the
    row or the data in the cell.
    """
    SORT_DESCENDING = 0
    SORT_ASCENDING = 1
    creation_counter = 0
    def __init__(self, label=None, detailed_label=None,
                 field_name=None, db_field=None,
                 image_url=None, image_width=None, image_height=None,
                 image_alt="", shrink=False, expand=False, sortable=False,
                 default_sort_dir=SORT_DESCENDING, link=False,
                 link_func=None, cell_clickable=False, css_class="", data_func=None):
        self.id = None
        self.datagrid = None
        self.field_name = field_name
        self.db_field = db_field or field_name
        self.label = label
        # self.detailed_label = detailed_label or self.label
        self.image_url = image_url
        self.image_width = image_width
        self.image_height = image_height
        self.image_alt = image_alt
        self.shrink = shrink
        self.expand = expand
        self.sortable = sortable
        self.default_sort_dir = default_sort_dir
        self.cell_clickable = cell_clickable
        self.link = link
        self.link_func = link_func or \
            (lambda x, y: self.datagrid.link_to_object(x, y))
        self.css_class = css_class
        self.data_func = data_func
        self.creation_counter = Column.creation_counter
        Column.creation_counter += 1
 
        # State
        self.active = False
        self.last = False
        self.width = 0
 
    def get_toggle_url(self):
        """
        Returns the URL of the current page with this column's visibility
        toggled.
        """
        columns = [column.id for column in self.datagrid.columns]
 
        if self.active:
            columns.remove(self.id)
        else:
            columns.append(self.id)
 
        return "?%scolumns=%s" % (self.get_url_params_except("columns"),
                                  ",".join(columns))
    toggle_url = property(get_toggle_url)
 
    def get_header(self):
        """
        Displays a sortable column header.
 
        The column header will include the current sort indicator, if it
        belongs in the sort list. It will also be made clickable in order
        to modify the sort order appropriately, if sortable.
        """
        in_sort = False
        sort_direction = self.SORT_DESCENDING
        sort_primary = False
        sort_url = ""
        unsort_url = ""
 
        if self.sortable:
            sort_list = self.datagrid.sort_list
 
            if sort_list:
                rev_column_id = "-%s" % self.id
                new_column_id = self.id
                cur_column_id = ""
 
                if self.id in sort_list:
                    # This column is currently being sorted in
                    # ascending order.
                    sort_direction = self.SORT_ASCENDING
                    cur_column_id = self.id
                    new_column_id = rev_column_id
                elif rev_column_id in sort_list:
                    # This column is currently being sorted in
                    # descending order.
                    sort_direction = self.SORT_DESCENDING
                    cur_column_id = rev_column_id
                    new_column_id = self.id
 
                if cur_column_id:
                    in_sort = True
                    sort_primary = (sort_list[0] == cur_column_id)
 
                    if not sort_primary:
                        # If this is not the primary column, we want to keep
                        # the sort order intact.
                        new_column_id = cur_column_id
 
                    # Remove this column from the current location in the list
                    # so we can move it to the front of the list.
                    sort_list.remove(cur_column_id)
 
                # Insert the column name into the beginning of the sort list.
                sort_list.insert(0, new_column_id)
            else:
                # There's no sort list to begin with. Make this column
                # the only entry.
                sort_list = [self.id]
 
            # We can only support two entries in the sort list, so truncate
            # this.
            # del(sort_list[2:])
            del(sort_list[1:])
 
            url_prefix = "?%ssort=" % self.get_url_params_except("sort",
                                                                 "datagrid-id",
                                                                 "gridonly",
                                                                 "columns")
            unsort_url = url_prefix + ','.join(sort_list[1:])
            sort_url   = url_prefix + ','.join(sort_list)
 
        return mark_safe(render_to_string(
            self.datagrid.column_header_template, {
                'MEDIA_URL': settings.MEDIA_URL,
                'column': self,
                'in_sort': in_sort,
                'sort_ascending': sort_direction == self.SORT_ASCENDING,
                'sort_primary': sort_primary,
                'sort_url': sort_url,
                'unsort_url': unsort_url,
            }))
    header = property(get_header)
 
    def get_url_params_except(self, *params):
        """
        Utility function to return a string containing URL parameters to
        this page with the specified parameter filtered out.
        """
        s = ""
 
        for key in self.datagrid.request.GET:
            if key not in params:
                s += "%s=%s&" % (key, self.datagrid.request.GET[key])
 
        return s
 
    def render_cell(self, obj):
        """
        Renders the table cell containing column data.
        """
        rendered_data = self.render_data(obj)
        css_class = ""
        url = ""
 
        if self.css_class:
            if callable(self.css_class):
                css_class = self.css_class(obj)
            else:
                css_class = self.css_class
 
        if self.link:
            try:
                url = self.link_func(obj, rendered_data)
            except AttributeError:
                pass
 
        self.label = self.label or ' '.join(self.id.split('_')).title()
        return mark_safe(render_to_string(self.datagrid.cell_template, {
            'MEDIA_URL': settings.MEDIA_URL,
            'column': self,
            'css_class': css_class,
            'url': url,
            'data': mark_safe(rendered_data)
        }))
 
    def render_data(self, obj):
        """
        Renders the column data to a string. This may contain HTML.
        """
        field_names = self.field_name.split('.')
        if len(field_names) > 1:
            field_name = field_names.pop(0)
            value = getattr(obj, field_name)
            if callable(value):
                value = value()
            if value is None:
                #NO further processing is possible, so bailout early.
                return value
            while field_names:
                field_name = field_names.pop(0)
                value = getattr(value, field_name)
                if callable(value):
                    value = value()
                if value is None:
                    #NO further processing is possible, so bailout early.
                    return value
        else:
            # value = getattr(obj, self.field_name)
            value = getattr(obj, self.db_field)
        if self.data_func:
            value = self.data_func(value)
        if callable(value):
            return value()
        else:
            return value
 
class NonDatabaseColumn(Column):
    def render_data(self, obj):
        if self.data_func:
            return self.data_func(obj)
        return self.label
 
class DateTimeColumn(Column):
    """
    A column that renders a date or time.
    """
    def __init__(self, label, format=None, sortable=True, *args, **kwargs):
        Column.__init__(self, label, sortable=sortable, *args, **kwargs)
        self.format = format
 
    def render_data(self, obj):
        # return date(getattr(obj, self.field_name), self.format)
        return date(getattr(obj, self.db_field), self.format)
 
class DateTimeSinceColumn(Column):
    """
    A column that renders a date or time relative to now.
    """
    def __init__(self, label, sortable=True, *args, **kwargs):
        Column.__init__(self, label, sortable=sortable, *args, **kwargs)
 
    def render_data(self, obj):
        # return _("%s ago") % timesince(getattr(obj, self.field_name))
        return _("%s ago") % timesince(getattr(obj, self.db_field))
 
 
class DataGrid(object):
    """
    A representation of a list of objects, sorted and organized by
    columns. The sort order and column lists can be customized. allowing
    users to view this data however they prefer.
 
    This is meant to be subclassed for specific uses. The subclasses are
    responsible for defining one or more column types. It can also set
    one or more of the following optional variables:
 
        * 'title':                  The title of the grid.
        * 'profile_sort_field':     The variable name in the user profile
                                    where the sort order can be loaded and
                                    saved.
        * 'profile_columns_field":  The variable name in the user profile
                                    where the columns list can be loaded and
                                    saved.
        * 'paginate_by':            The number of items to show on each page
                                    of the grid. The default is 50.
        * 'paginate_orphans':       If this number of objects or fewer are
                                    on the last page, it will be rolled into
                                    the previous page. The default is 3.
        * 'page':                   The page to display. If this is not
                                    specified, the 'page' variable passed
                                    in the URL will be used, or 1 if that is
                                    not specified.
        * 'listview_template':      The template used to render the list view.
                                    The default is 'datagrid/listview.html'
        * 'column_header_template': The template used to render each column
                                    header. The default is
                                    'datagrid/column_header.html'
        * 'cell_template':          The template used to render a cell of
                                    data. The default is 'datagrid/cell.html'
        * 'optimize_sorts':         Whether or not to optimize queries when
                                    using multiple sorts. This can offattr_metaer a
                                    speed improvement, but may need to be
                                    turned off for more advanced querysets
                                    (such as when using extra()).
                                    The default is True.
    """
    def __init__(self, request, queryset, title="", extra_context={},
                 optimize_sorts=True, listview_template='datagrid/listview.html', 
                 column_header_template='datagrid/column_header.html', cell_template='datagrid/cell.html'):
        self.request = request
        self.queryset = queryset
        self.rows = []
        self.columns = []
        self.all_columns = []
        self.db_field_map = {}
        self.paginator = None
        self.page = None
        self.sort_list = None
        self.state_loaded = False
        self.page_num = 0
        self.id = None
        self.extra_context = dict(extra_context)
        self.optimize_sorts = optimize_sorts
 
        if not hasattr(request, "datagrid_count"):
            request.datagrid_count = 0
 
        self.id = "datagrid-%s" % request.datagrid_count
        request.datagrid_count += 1
 
        # Customizable variables
        # self.title = title
        self.grid_header = title
        self.profile_sort_field = None
        self.profile_columns_field = None
        self.paginate_by = 10
        self.paginate_orphans = 3
        self.listview_template = listview_template
        self.column_header_template = column_header_template
        self.cell_template = cell_template
 
 
        for attr in dir(self):
            column = getattr(self, attr)
            if isinstance(column, Column):
                self.all_columns.append(column)
                column.datagrid = self
                column.id = attr
 
                # Reset the column.
                column.active = False
                column.last = False
                column.width = 0
 
                if not column.field_name:
                    column.field_name = column.id
 
                if not column.db_field:
                    column.db_field = column.field_name
 
                self.db_field_map[column.id] = column.db_field
 
        self.all_columns.sort(key=lambda x: x.creation_counter)
        self.columns = self.all_columns
 
        # self.default_columns = [el.label or ' '.join(el.id.split()).title() for el in self.all_columns]#TODO:FOR now
        # self.default_sort = self.default_columns[0]
        self.default_sort = self.all_columns[0].id
 
        #Get the meta fields
        meta = getattr(self, 'Meta', None)
        page_size = request.GET.get('page_size', None)
        if page_size:
            try:
                self.paginate_by = int(page_size)
                if self.paginate_by < self.paginate_orphans:
                    #Special case, because in this case other values will result in weird result
                    self.paginate_orphans = 0
            except ValueError:
                pass
 
        #Handle controls
        self.pagination_control_widget = getattr(meta, 'pagination_control_widget', False)
        self.get_pdf_link = getattr(meta, 'get_pdf_link', False)
        self.get_csv_link = getattr(meta, 'get_csv_link', False)
        self.filter_fields = getattr(meta, 'filter_fields', [])
        self.search_fields = getattr(meta, 'search_fields', [])
        self.filtering_options = {}
        if self.filter_fields:
            filtering_options = {}
            #TODO: This is very costly for large querysets so we may want to cache this, or do this in SQL
            for field in self.filter_fields:
                filtering_options[field] = set([getattr(el, field) for el in queryset])
            self.filtering_options = filtering_options
 
 
 
    def load_state(self):
        """
        Loads the state of the datagrid.
 
        This will retrieve the user-specified or previously stored
        sorting order and columns list, as well as any state a subclass
        may need.
        """
 
        if self.state_loaded:
            return
 
        profile_sort_list = None
        profile_columns_list = None
        profile = None
        profile_dirty = False
 
        # Get the saved settings for this grid in the profile. These will
        # work as defaults and allow us to determine if we need to save
        # the profile.
        if self.request.user.is_authenticated():
            try:
                profile = self.request.user.get_profile()
 
                if self.profile_sort_field:
                    profile_sort_list = \
                        getattr(profile, self.profile_sort_field, None)
 
                if self.profile_columns_field:
                    profile_columns_list = \
                        getattr(profile, self.profile_columns_field, None)
            except SiteProfileNotAvailable:
                pass
            except ObjectDoesNotExist:
                pass
 
 
        # Figure out the columns we're going to display
        # We're also going to calculate the column widths based on the
        # shrink and expand values.
        # colnames_str = self.request.GET.get('columns', profile_columns_list)
 
        # if colnames_str:
        #     colnames = colnames_str.split(',')
        # else:
        colnames = self.all_columns
            # colnames_str = ",".join(self.default_columns)
 
        expand_columns = []
        normal_columns = []
 
        for colname in colnames:
            try:
                column = getattr(self, colname.id)
            except AttributeError:
                # The user specified a column that doesn't exist. Skip it.
                continue
 
            if column not in self.columns:
                self.columns.append(column)
            column.active = True
 
            if column.expand:
                # This column is requesting all remaining space. Save it for
                # later so we can tell how much to give it. Each expanded
                # column will count as two normal columns when calculating
                # the normal sized columns.
                expand_columns.append(column)
            elif column.shrink:
                # Make this as small as possible.
                column.width = 0
            else:
                # We'll divide the column widths equally after we've built
                # up the lists of expanded and normal sized columns.
                normal_columns.append(column)
 
        self.columns[-1].last = True
 
        # Try to figure out the column widths for each column.
        # We'll start with the normal sized columns.
        total_pct = 100
 
        # Each expanded column counts as two normal columns.
        normal_column_width = total_pct / (len(self.columns) +
                                           len(expand_columns))
 
        for column in normal_columns:
            column.width = normal_column_width
            total_pct -= normal_column_width
 
        if len(expand_columns) > 0:
            expanded_column_width = total_pct / len(expand_columns)
        else:
            expanded_column_width = 0
 
        for column in expand_columns:
            column.width = expanded_column_width
 
 
        # Now get the sorting order for the columns.
        sort_str = self.request.GET.get('sort', profile_sort_list)
 
        if sort_str:
            self.sort_list = sort_str.split(',')
        else:
            self.sort_list = [self.default_sort]
            sort_str = ",".join(self.sort_list)
 
 
        # A subclass might have some work to do for loading and saving
        # as well.
        if self.load_extra_state(profile):
            profile_dirty = True
 
 
        # Now that we have all that, figure out if we need to save new
        # settings back to the profile.
        # if profile:
        #     if self.profile_columns_field and \
        #        colnames_str != profile_columns_list:
        #         setattr(profile, self.profile_columns_field, colnames_str)
        #         profile_dirty = True
        # 
        #     if self.profile_sort_field and sort_str != profile_sort_list:
        #         setattr(profile, self.profile_sort_field, sort_str)
        #         profile_dirty = True
        # 
        #     if profile_dirty:
        #         profile.save()
 
        self.state_loaded = True
 
        # Fetch the list of objects and have it ready.
        self.precompute_objects()
 
 
    def load_extra_state(self, profile):
        """
        Loads any extra state needed for this grid.
 
        This is used by subclasses that may have additional data to load
        and save. This should return True if any profile-stored state has
        changed, or False otherwise.
        """
        return False
 
    def precompute_objects(self):
        """
        Builds the queryset and stores the list of objects for use in
        rendering the datagrid.
        """
        query = self.queryset
        use_select_related = False
        # Generate the actual list of fields we'll be sorting by
        sort_list = []
        for sort_item in self.sort_list:
            if sort_item[0] == "-":
                base_sort_item = sort_item[1:]
                prefix = "-"
            else:
                base_sort_item = sort_item
                prefix = ""
 
            if sort_item and base_sort_item in self.db_field_map:
                db_field = self.db_field_map[base_sort_item]
                sort_list.append(prefix + db_field)
 
                # Lookups spanning tables require that we query from those
                # tables. In order to keep things simple, we'll just use
                # select_related so that we don't have to figure out the
                # table relationships. We only do this if we have a lookup
                # spanning tables.
                if '.' in db_field:
                    use_select_related = True
 
        if sort_list:
            query = query.order_by(*sort_list)
        self.paginator = Paginator(query, self.paginate_by,
                                           self.paginate_orphans)
        page_num = self.request.GET.get('page', 1)
 
        # Accept either "last" or a valid page number.
        if page_num == "last":
            page_num = self.paginator.num_pages
 
        try:
            self.page = self.paginator.page(page_num)
        except InvalidPage:
            raise Http404
 
        self.rows = []
        self.rows_raw = []
        id_list = None
 
        if self.optimize_sorts and len(sort_list) > 0:
            # This can be slow when sorting by multiple columns. If we
            # have multiple items in the sort list, we'll request just the
            # IDs and then fetch the actual details from that.
            id_list = list(self.page.object_list.distinct().values_list(
                'pk', flat=True))
 
            # Make sure to unset the order. We can't meaningfully order these
            # results in the query, as what we really want is to keep it in
            # the order specified in id_list, and we certainly don't want
            # the database to do any special ordering (possibly slowing things
            # down). We'll set the order properly in a minute.
            self.page.object_list = self.post_process_queryset(
                self.queryset.model.objects.filter(pk__in=id_list).order_by())
 
        if use_select_related:
            self.page.object_list = \
                self.page.object_list.select_related(depth=1)
 
        if id_list:
            # The database will give us the items in a more or less random
            # order, since it doesn't know to keep it in the order provided by
            # the ID list. This will place the results back in the order we
            # expect.
            index = dict([(id, pos) for (pos, id) in enumerate(id_list)])
            object_list = [None] * len(id_list)
 
            for obj in list(self.page.object_list):
                object_list[index[obj.id]] = obj
        else:
            # Grab the whole list at once. We know it won't be too large,
            # and it will prevent one query per row.
            object_list = list(self.page.object_list)
 
        for obj in object_list:
            self.rows.append({
                'object': obj,
                'cells': [column.render_cell(obj) for column in self.columns],
                'data': [column.render_data(obj) for column in self.columns],
            })
 
    def post_process_queryset(self, queryset):
        """
        Processes a QuerySet after the initial query has been built and
        pagination applied. This is only used when optimizing a sort.
 
        By default, this just returns the existing queryset. Custom datagrid
        subclasses can override this to add additional queries (such as
        subqueries in an extra() call) for use in the cell renderers.
 
        When optimize_sorts is True, subqueries (using extra()) on the initial
        QuerySet passed to the datagrid will be stripped from the final
        result. This function can be used to re-add those subqueries.
        """
        return queryset
 
    def handle_search(self):
        if not self.search_fields:
            return
        query = self.request.GET.get('q', None)
        if not query:
            return
        query_criteria=Q(id=-1)
        for field in self.search_fields:
            field = field+"__icontains"
            query_criteria  = query_criteria | Q(**{field:query})
        self.queryset = self.queryset.filter(query_criteria)
 
    def handle_filter(self):
        queryset = self.queryset
        if not self.filter_fields:
            return
        for field in self.filter_fields:
            query = self.request.GET.get(field, None)
            if query:
                self.queryset = queryset.filter(**{field: query})
 
 
 
    def render_listview(self):
        """
        Renders the standard list view of the grid.
 
        This can be called from templates.
        """
        self.handle_search()
        self.handle_filter()
        self.load_state()
        context = {
            'datagrid': self,
            'request': self.request,
            'is_paginated': self.page.has_other_pages(),
            'results_per_page': self.paginate_by,
            'has_next': self.page.has_next(),
            'has_previous': self.page.has_previous(),
            'page': self.page.number,
            'next': self.page.next_page_number(),
            'previous': self.page.previous_page_number(),
            'last_on_page': self.page.end_index(),
            'first_on_page': self.page.start_index(),
            'pages': self.paginator.num_pages,
            'hits': self.paginator.count,
            'page_range': self.paginator.page_range,
            'pagination_control_widget': self.pagination_control_widget,
            'get_pdf_link': self.get_pdf_link,
            'get_csv_link': self.get_csv_link,
            'filter_fields': self.filter_fields,
            'search_fields': self.search_fields,
            'filtering_options': self.filtering_options.items(),
        }
 
 
        context.update(self.extra_context)
 
        return mark_safe(render_to_string(self.listview_template,
            RequestContext(self.request, context)))
 
    @cache_control(no_cache=True, no_store=True, max_age=0,
                   must_revalidate=True)
    def render_listview_to_response(self):
        """
        Renders the listview to a response, preventing caching in the
        process.
        """
        return HttpResponse(unicode(self.render_listview()))
 
    def render_to_response(self, template_name, extra_context={}):
        """
        Renders a template containing this datagrid as a context variable.
        """
        self.handle_search()
        self.handle_filter()
        self.load_state()
 
 
        # If the caller is requesting just this particular grid, return it.
        if self.request.GET.get('gridonly', False) and \
           self.request.GET.get('datagrid-id', None) == self.id:
            return self.render_listview_to_response()
 
        context = {
            'datagrid': self
        }
        context.update(extra_context)
        context.update(self.extra_context)
        if self.request.GET.get('is_pdf', None):
            import ho.pisa as pisa
            file_data = render_to_string('datagrid/as_pdf.pdf', context)
            myfile = StringIO.StringIO()
            pisa.CreatePDF(file_data, myfile)
            myfile.seek(0)
            response =  HttpResponse(myfile, mimetype='application/pdf')
            response['Content-Disposition'] = 'attachment; filename=data.pdf'
            return response
        elif self.request.GET.get('is_csv', None):
            file_data = render_to_string('datagrid/as_csv.csv', context)
            response =  HttpResponse(file_data, mimetype='text.csv')
            response['Content-Disposition'] = 'attachment; filename=data.csv'
            return response
 
 
        return render_to_response(template_name, RequestContext(self.request,
                                                                context))
 
    @staticmethod
    def link_to_object(obj, value):
        return obj.get_absolute_url()
 
    @staticmethod
    def link_to_value(obj, value):
        return value.get_absolute_url()