From eb7c65eadbf49ae5e2f620327550afd0016da25c Mon Sep 17 00:00:00 2001 From: Shea Craig Date: Tue, 5 Sep 2017 14:12:51 -0400 Subject: [PATCH 01/88] Rewrite inventory to use queries for all views except ApplicationList ApplicationList has some custom data columns that require the group_type and group_id values, which can't be passed through except as part of the path, at least in my experimentation. This works, although it's not ideal. After updating to the newer Datatableview version and API I'll try again. --- .../inventory/application_detail.html | 6 +- inventory/templates/inventory/csv_export.html | 2 +- inventory/urls.py | 30 ++-- inventory/views.py | 134 +++++++++++------- sal/decorators.py | 6 +- server/templates/server/index.html | 2 +- 6 files changed, 99 insertions(+), 81 deletions(-) diff --git a/inventory/templates/inventory/application_detail.html b/inventory/templates/inventory/application_detail.html index 5a7330f0..7700b2b8 100644 --- a/inventory/templates/inventory/application_detail.html +++ b/inventory/templates/inventory/application_detail.html @@ -16,19 +16,19 @@

Bundle Name:
{{ object.bundlename }}
Total Install Count:
-
{{ install_count }}
+
{{ install_count }}

Installed Versions
{% for version in versions %}
{{ version.version }}:
-
{{ version.count }}
+
{{ version.count }} {{ path.path }}
{% endfor %}
Installed Paths
{% for path in paths %}
Path:
-
{{ path.count }} {{ path.path }}
+
{{ path.count }} {{ path.path }}
{% endfor %} {% endblock %} diff --git a/inventory/templates/inventory/csv_export.html b/inventory/templates/inventory/csv_export.html index 49914ff2..d5846977 100644 --- a/inventory/templates/inventory/csv_export.html +++ b/inventory/templates/inventory/csv_export.html @@ -1,2 +1,2 @@ -
  • +
  • Export CSV
  • diff --git a/inventory/urls.py b/inventory/urls.py index fb5321eb..d4642bed 100644 --- a/inventory/urls.py +++ b/inventory/urls.py @@ -6,29 +6,17 @@ # Hash url(r'^hash/(?P.+)/$', views.inventory_hash), # Application Detail View - url(r'^application/' - r'(?P[0-9]+)/' - r'(?P[a-zA-Z_-]+)/' - r'(?P[0-9]+)/$', - views.ApplicationDetailView.as_view(), - name="application_detail"), + url( + r'^application/(?P[0-9]+)/$', + views.ApplicationDetailView.as_view(), name="application_detail"), # Install List View - url(r'^list/(?P.+)/' - r'(?P[0-9]+)/' - r'(?P[0-9]+)/' - r'(?P[a-zA-Z]+)/{1}' - r'(?P.+)/$', + url( + r'^list/(?P[0-9]+)/$', views.InventoryListView.as_view(), name='inventory_list'), - # # CSV Export View - url(r'^csv_export/' - r'(?P[a-zA-Z_-]+)/' - r'(?P[0-9]+)/' - r'(?P[0-9]+)/' - r'(?P[a-zA-Z]+)/' - r'(?P.+)/$', - views.CSVExportView.as_view(), name='csv_export'), + # CSV Export View + url( + r'^csv_export/$', views.CSVExportView.as_view(), name='csv_export'), # GA Application List - url(r'^(?P.+)/' - r'(?P[0-9]+)/$', views.ApplicationListView.as_view(), + url(r'^(?P.+)/(?P[0-9]+)/$', views.ApplicationListView.as_view(), name="application_list"), ] diff --git a/inventory/views.py b/inventory/views.py index a06d1363..131ed687 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -3,7 +3,7 @@ import plistlib from datetime import datetime from django.utils import timezone -from urllib import quote +from urllib import quote, urlencode # third-party import unicodecsv as csv @@ -56,14 +56,14 @@ class GroupMixin(object): model = None @classmethod - def get_business_unit(cls, **kwargs): + def get_business_unit(cls, request): """Return the business unit associated with this request.""" instance = None - group_class = cls.classes[kwargs["group_type"]] + group_class = cls.classes[request.GET.get("group_type", 'all')] if group_class: instance = get_object_or_404( - group_class, pk=kwargs["group_id"]) + group_class, pk=request.GET.get("group_id")) # Implicitly returns BusinessUnit, or None if that is the type. # No need for an extra test. @@ -75,11 +75,18 @@ def get_business_unit(cls, **kwargs): return instance def get_group_instance(self): - group_type = self.kwargs["group_type"] + if 'group_type' in self.kwargs: + group_type = self.kwargs['group_type'] + else: + group_type = self.request.GET.get('group_type', 'all') + if 'group_id' in self.kwargs: + group_id = self.kwargs['group_id'] + else: + group_id = self.request.GET.get('group_id', '0') group_class = self.classes[group_type] if group_class: instance = get_object_or_404( - group_class, pk=self.kwargs["group_id"]) + group_class, pk=group_id) else: instance = None @@ -88,7 +95,7 @@ def get_group_instance(self): def filter_queryset_by_group(self, queryset): """Filter queryset to only include group data. - The filter depends on the instance's group_type kwarg. The + The filter depends on the request's group_type value. The provided queryset is filtered to include only records matching the view's specified BusinessUnit, MachineGroup, Machine (or not filtered at all for GA access). @@ -101,9 +108,14 @@ def filter_queryset_by_group(self, queryset): """ self.group_instance = self.get_group_instance() # No need to filter if group_instance is None. + if 'group_type' in self.kwargs: + group_type = self.kwargs['group_type'] + else: + group_type = self.request.GET.get('group_type', 'all') + if self.group_instance: filter_path = self.access_filter[queryset.model]\ - [self.classes[self.kwargs["group_type"]]] + [self.classes[group_type]] # If filter_path is Machine there is nothing to filter on. if filter_path: @@ -163,27 +175,32 @@ def get_queryset(self): Application, pk=self.kwargs["application_id"]) queryset = queryset.filter(inventoryitem__application=self.application) + # Save request values so we don't have to keep looking them up. + self.group_type = self.request.GET.get("group_type", "all") + self.group_id = self.request.GET.get("group_id", "0") + self.field_type = self.request.GET.get("field_type", "all") + self.field_value = self.request.GET.get("field_value", "") + # Filter again based on criteria. - field_type = self.kwargs["field_type"] - if field_type == "path": + if self.field_type == "path": queryset = queryset.filter( - inventoryitem__path=self.kwargs["field_value"]) - elif field_type == "version": + inventoryitem__path=self.field_value) + elif self.field_type == "version": queryset = queryset.filter( - inventoryitem__version=self.kwargs["field_value"]) + inventoryitem__version=self.field_value) return queryset.distinct() def get_context_data(self, **kwargs): context = super(InventoryListView, self).get_context_data(**kwargs) context["application_id"] = self.application.id - context["group_type"] = self.kwargs["group_type"] - context["group_id"] = self.kwargs["group_id"] + context["group_type"] = self.group_type + context["group_id"] = self.group_id context["group_name"] = (self.group_instance.name if hasattr( self.group_instance, "name") else None) context["app_name"] = self.application.name - context["field_type"] = self.kwargs["field_type"] - context["field_value"] = self.kwargs["field_value"] + context["field_type"] = self.field_type + context["field_value"] = self.field_value return context def format_date(self, instance, *args, **kwargs): @@ -193,16 +210,18 @@ def get_machine_link(self, instance, *args, **kwargs): url = reverse( "machine_detail", kwargs={"machine_id": instance.pk}) - return '{}'.format(url, instance.hostname.encode("utf-8")) + return '{}'.format( + url, instance.hostname.encode("utf-8")) def get_install_count(self, instance, *args, **kwargs): queryset = instance.inventoryitem_set.filter( application=self.application) - field_type = self.kwargs["field_type"] - if field_type == "path": - queryset = queryset.filter(path=self.kwargs["field_value"]) - elif field_type == "version": - queryset = queryset.filter(version=self.kwargs["field_value"]) + if self.field_type == "path": + queryset = queryset.filter( + path=self.field_value) + elif self.field_type == "version": + queryset = queryset.filter( + version=self.field_value) return queryset.count() @@ -251,7 +270,11 @@ def get_queryset(self): def get_name_link(self, instance, *args, **kwargs): self.kwargs["pk"] = instance.pk - url = reverse("application_detail", kwargs=self.kwargs) + url = "{}?{}".format( + reverse("application_detail", kwargs={'pk': instance.pk}), + urlencode( + {'group_type': self.kwargs['group_type'], + 'group_id': self.kwargs['group_id']})) return '{}'.format(url, instance.name.encode("utf-8")) def get_install_count(self, instance, *args, **kwargs): @@ -261,12 +284,13 @@ def get_install_count(self, instance, *args, **kwargs): # Build a link to InventoryListView for install count badge. url_kwargs = { - "group_type": self.kwargs["group_type"], - "group_id": self.kwargs["group_id"], - "application_id": instance.pk, - "field_type": "all", - "field_value": 0} - url = reverse("inventory_list", kwargs=url_kwargs) + "application_id": instance.pk} + querystring = urlencode( + {"group_type": self.kwargs['group_type'], "group_id": + self.kwargs['group_id']}) + url = "{}?{}".format( + reverse("inventory_list", kwargs=url_kwargs), + querystring) # Build the link. anchor = '{}'.format( @@ -276,7 +300,7 @@ def get_install_count(self, instance, *args, **kwargs): def get_context_data(self, **kwargs): context = super(ApplicationListView, self).get_context_data(**kwargs) self.group_instance = self.get_group_instance() - context["group_type"] = self.kwargs["group_type"] + context["group_type"] = self.kwargs['group_type'] if hasattr(self.group_instance, "name"): context["group_name"] = self.group_instance.name elif hasattr(self.group_instance, "hostname"): @@ -340,8 +364,8 @@ def _build_context_data(self, context, details, versions, paths): # Get the total number of installations. context["install_count"] = details.count() # Add in access data. - context["group_type"] = self.kwargs["group_type"] - context["group_id"] = self.kwargs["group_id"] + context["group_type"] = self.request.GET.get("group_type", 'all') + context["group_id"] = self.request.GET.get("group_id", '0') if hasattr(self.group_instance, "name"): group_name = self.group_instance.name elif hasattr(self.group_instance, "hostname"): @@ -362,14 +386,20 @@ def get(self, request, *args, **kwargs): # Filter data by access level queryset = self.filter_queryset_by_group(self.model.objects) - if kwargs["application_id"] == "0": + application_id = self.request.GET.get('pk', '0') + group_type = self.request.GET.get('group_type', 'all') + group_id = self.request.GET.get('group_id', '0') + field_type = self.request.GET.get('field_type', 'all') + field_value = self.request.GET.get('field_value', '') + + + if application_id == "0": # All Applications. self.set_header( ["Name", "BundleID", "BundleName", "Install Count"]) - self.components = ["application", "list", "for", - self.kwargs["group_type"]] - if self.kwargs["group_type"] != "all": - self.components.append(self.kwargs["group_id"]) + self.components = ['application', 'list', 'for', group_type] + if group_type != "all": + self.components.append(group_id) if is_postgres(): apps = [self.get_application_entry(item, queryset) for item in @@ -387,22 +417,19 @@ def get(self, request, *args, **kwargs): # Inventory List for one application. self.set_header( ["Hostname", "Serial Number", "Last Checkin", "Console User"]) - self.components = ["application", self.kwargs["application_id"], - "for", self.kwargs["group_type"]] - if self.kwargs["group_type"] != "all": - self.components.append(self.kwargs["group_id"]) - if self.kwargs["field_type"] != "all": + self.components = ["application", application_id, + "for", group_type] + if group_type != "all": + self.components.append(group_id) + if field_type != "all": self.components.extend( - ["where", self.kwargs["field_type"], "is", - quote(self.kwargs["field_value"])]) + ["where", field_type, "is", quote(field_value)]) - queryset = queryset.filter(application=kwargs["application_id"]) - if kwargs["field_type"] == "path": - queryset = queryset.filter( - path=kwargs["field_value"]) - elif kwargs["field_type"] == "version": - queryset = queryset.filter( - version=kwargs["field_value"]) + queryset = queryset.filter(application=application_id) + if field_type == "path": + queryset = queryset.filter(path=field_value) + elif field_type == "version": + queryset = queryset.filter(version=field_value) data = [self.get_machine_entry(item, queryset) for item in queryset.select_related("machine")] @@ -483,7 +510,8 @@ def inventory_submit(request): inventory_meta.save() if is_postgres(): - InventoryItem.objects.bulk_create(inventory_items_to_be_created) + InventoryItem.objects.bulk_create( + inventory_items_to_be_created) machine.save() return HttpResponse( "Inventory submmitted for %s.\n" % diff --git a/sal/decorators.py b/sal/decorators.py index eacea5d5..7b33f6fa 100644 --- a/sal/decorators.py +++ b/sal/decorators.py @@ -40,8 +40,10 @@ def class_access_required(cls): """ def access_required(f): def decorator(*args, **kwargs): - user = args[0].user - business_unit = cls.get_business_unit(**kwargs) + # The request object is the first arg to a view + request = args[0] + user = request.user + business_unit = cls.get_business_unit(request) if is_global_admin(user) or has_access(user, business_unit): return f(*args, **kwargs) diff --git a/server/templates/server/index.html b/server/templates/server/index.html index bcccc961..7070cd42 100755 --- a/server/templates/server/index.html +++ b/server/templates/server/index.html @@ -77,7 +77,7 @@ {% endif %} {% endif %} {% if user.userprofile.level == 'GA' %} -
  • Application Inventory
  • +
  • Application Inventory
  • {% endif %}
  • Business Units From 248e5502a75e4c88197534bccd05ad06c64e4af2 Mon Sep 17 00:00:00 2001 From: Shea Craig Date: Wed, 6 Sep 2017 11:43:40 -0400 Subject: [PATCH 02/88] Update included datatableview --- datatableview/cache.py | 113 ++++++ datatableview/columns.py | 49 ++- datatableview/compat.py | 24 ++ datatableview/datatables.py | 333 +++++++++++++++--- datatableview/helpers.py | 40 ++- datatableview/static/js/datatableview.js | 261 +++++++------- datatableview/static/js/datatableview.min.js | 2 +- .../datatableview/bootstrap_structure.html | 1 + .../datatableview/default_structure.html | 2 +- .../datatableview/legacy_structure.html | 2 +- datatableview/tests/__init__.py | 2 + .../example_app/fixtures/initial_data_17.json | 99 ++++++ .../fixtures/initial_data_modern.json | 38 +- .../example_app/migrations/0001_initial.py | 49 +++ .../migrations/0002_entry_is_published.py | 20 ++ .../example_app/migrations/__init__.py | 0 .../example_project/example_app/models.py | 1 + .../example_app/templates/base.html | 1 + .../templates/demos/col_reorder.html | 4 +- .../example_app/templates/index.html | 4 +- .../example_project/example_app/urls.py | 8 +- .../example_project/example_app/views.py | 101 ++++-- .../example_project/settings.py | 27 +- .../example_project/example_project/urls.py | 8 +- datatableview/tests/test_datatables.py | 145 +++++--- datatableview/tests/test_helpers.py | 20 +- datatableview/tests/test_utils.py | 9 +- datatableview/tests/test_views.py | 17 +- datatableview/tests/testcase.py | 8 +- datatableview/utils.py | 37 +- datatableview/views/base.py | 115 +++--- datatableview/views/legacy.py | 9 +- datatableview/views/xeditable.py | 10 +- 33 files changed, 1144 insertions(+), 415 deletions(-) create mode 100755 datatableview/cache.py create mode 100755 datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_17.json create mode 100755 datatableview/tests/example_project/example_project/example_app/migrations/0001_initial.py create mode 100755 datatableview/tests/example_project/example_project/example_app/migrations/0002_entry_is_published.py create mode 100755 datatableview/tests/example_project/example_project/example_app/migrations/__init__.py diff --git a/datatableview/cache.py b/datatableview/cache.py new file mode 100755 index 00000000..04da774c --- /dev/null +++ b/datatableview/cache.py @@ -0,0 +1,113 @@ +import inspect +import hashlib +import logging + +from django.core.cache import caches +from django.conf import settings + +log = logging.getLogger(__name__) + + +class cache_types(object): + NONE = None + DEFAULT = 'default' + SIMPLE = 'simple' # Stores queryset objects directly in cache + PK_LIST = 'pk_list' # Stores queryset pks in cache for later expansion back to queryset + + +try: + CACHE_BACKEND = settings.DATATABLEVIEW_CACHE_BACKEND +except AttributeError: + CACHE_BACKEND = 'default' + +try: + CACHE_PREFIX = settings.DATATABLEVIEW_CACHE_PREFIX +except AttributeError: + CACHE_PREFIX = 'datatableview_' + +try: + DEFAULT_CACHE_TYPE = settings.DATATABLEVIEW_DEFAULT_CACHE_TYPE +except AttributeError: + DEFAULT_CACHE_TYPE = cache_types.SIMPLE + +try: + CACHE_KEY_HASH = settings.DATATABLEVIEW_CACHE_KEY_HASH +except AttributeError: + CACHE_KEY_HASH = True + +try: + CACHE_KEY_HASH_LENGTH = settings.DATATABLEVIEW_CACHE_KEY_HASH_LENGTH +except AttributeError: + CACHE_KEY_HASH_LENGTH = None + +cache = caches[CACHE_BACKEND] + + +hash_slice = None +if CACHE_KEY_HASH: + hash_slice = slice(None, CACHE_KEY_HASH_LENGTH) + +def _hash_key_component(s): + return hashlib.sha1(s).hexdigest()[hash_slice] + + +def get_cache_key(datatable_class, view=None, user=None, **kwargs): + """ + Returns a cache key unique to the current table, and (if available) the request user. + + The ``view`` argument should be the class reference itself, since it is easily obtainable + in contexts where the instance is not available. + """ + + datatable_name = datatable_class.__name__ + if datatable_name.endswith('_Synthesized'): + datatable_name = datatable_name[:-12] + datatable_id = '%s.%s' % (datatable_class.__module__, datatable_name) + if CACHE_KEY_HASH: + datatable_id = _hash_key_component(datatable_id) + + cache_key = 'datatable_%s' % (datatable_id,) + + if view: + if not inspect.isclass(view): + # Reduce view to its class + view = view.__class__ + + view_id = '%s.%s' % (view.__module__, view.__name__) + if CACHE_KEY_HASH: + view_id = _hash_key_component(view_id) + cache_key += '__view_%s' % (view_id,) + + if user and user.is_authenticated(): + cache_key += '__user_%s' % (user.pk,) + + # All other kwargs are used directly to create a hashed suffix + # Order the kwargs by key name, then convert them to their repr() values. + items = sorted(kwargs.items(), key=lambda item: item[0]) + values = [] + for k, v in items: + values.append('%r:%r' % (k, v)) + + if values: + kwargs_id = '__'.join(values) + kwargs_id = _hash_key_component(kwargs_id) + cache_key += '__kwargs_%s' % (kwargs_id,) + + log.debug("Cache key derived for %r: %r (from kwargs %r)", datatable_class, cache_key, values) + + return cache_key + + +def get_cached_data(datatable, **kwargs): + """ Returns the cached object list under the appropriate key, or None if not set. """ + cache_key = '%s%s' % (CACHE_PREFIX, datatable.get_cache_key(**kwargs)) + data = cache.get(cache_key) + log.debug("Reading data from cache at %r: %r", cache_key, data) + return data + + +def cache_data(datatable, data, **kwargs): + """ Stores the object list in the cache under the appropriate key. """ + cache_key = '%s%s' % (CACHE_PREFIX, datatable.get_cache_key(**kwargs)) + log.debug("Setting data to cache at %r: %r", cache_key, data) + cache.set(cache_key, data) diff --git a/datatableview/columns.py b/datatableview/columns.py index 16457659..3b85fd67 100755 --- a/datatableview/columns.py +++ b/datatableview/columns.py @@ -8,7 +8,7 @@ except ImportError: pass -from django import get_version +import django from django.db import models from django.db.models import Model, Manager, Q from django.db.models.fields import FieldDoesNotExist @@ -16,9 +16,9 @@ from django.utils.encoding import smart_text from django.utils.safestring import mark_safe try: - from django.forms.util import flatatt -except ImportError: from django.forms.utils import flatatt +except ImportError: + from django.forms.util import flatatt from django.template.defaultfilters import slugify try: from django.utils.encoding import python_2_unicode_compatible @@ -288,19 +288,20 @@ def prep_search_value(self, term, lookup_type): # We avoid making changes that the Django ORM can already do for us multi_terms = None - if lookup_type == "in": - in_bits = re.split(r',\s*', term) - if len(in_bits) > 1: - multi_terms = in_bits - else: - term = None + if isinstance(term, six.text_type): + if lookup_type == "in": + in_bits = re.split(r',\s*', term) + if len(in_bits) > 1: + multi_terms = in_bits + else: + term = None - if lookup_type == "range": - range_bits = re.split(r'\s*-\s*', term) - if len(range_bits) == 2: - multi_terms = range_bits - else: - term = None + if lookup_type == "range": + range_bits = re.split(r'\s*-\s*', term) + if len(range_bits) == 2: + multi_terms = range_bits + else: + term = None if multi_terms: return filter(None, (self.prep_search_value(multi_term, lookup_type) for multi_term in multi_terms)) @@ -354,7 +355,11 @@ def search(self, model, term, lookup_types=None): for sub_source in self.expand_source(source): modelfield = resolve_orm_path(model, sub_source) if modelfield.choices: - for db_value, label in modelfield.get_flatchoices(): + if hasattr(modelfield, 'get_choices'): + choices = modelfield.get_choices() + else: + choices = modelfield.get_flatchoices() + for db_value, label in choices: if term.lower() in label.lower(): k = '%s__exact' % (sub_source,) column_queries.append(Q(**{k: str(db_value)})) @@ -417,6 +422,13 @@ def attributes(self): class TextColumn(Column): model_field_class = models.CharField handles_field_classes = [models.CharField, models.TextField, models.FileField] + + # Add UUIDField if present in this version of Django + try: + handles_field_classes.append(models.UUIDField) + except AttributeError: + pass + lookup_types = ('icontains', 'in') @@ -470,7 +482,7 @@ class DateTimeColumn(DateColumn): lookups_types = ('exact', 'in', 'range', 'year', 'month', 'day', 'week_day') -if get_version().split('.') >= ['1', '6']: +if django.VERSION >= (1, 6): DateTimeColumn.lookup_types += ('hour', 'minute', 'second') @@ -481,7 +493,8 @@ class BooleanColumn(Column): def prep_search_value(self, term, lookup_type): term = term.lower() - if term == 'true': + # Allow column's own label to represent a true value + if term == 'true' or term.lower() in self.label.lower(): term = True elif term == 'false': term = False diff --git a/datatableview/compat.py b/datatableview/compat.py index c3be757d..6dd10c2a 100755 --- a/datatableview/compat.py +++ b/datatableview/compat.py @@ -1,6 +1,13 @@ # -*- encoding: utf-8 -*- """ Backports of code left behind by new versions of Django. """ +import django +from django.utils.html import escape +try: + from django.utils.encoding import escape_uri_path as django_escape_uri_path +except ImportError: + django_escape_uri_path = None + import six @@ -19,3 +26,20 @@ def python_2_unicode_compatible(klass): klass.__unicode__ = klass.__str__ klass.__str__ = lambda self: self.__unicode__().encode('utf-8') return klass + + +USE_LEGACY_FIELD_API = django.VERSION < (1, 8) + +def get_field(opts, field_name): + """ Retrieves a field instance from a model opts object according to Django version. """ + if not USE_LEGACY_FIELD_API: + field = opts.get_field(field_name) + direct = not field.auto_created + else: + field, _, direct, _ = opts.get_field_by_name(field_name) + return field, direct + +def escape_uri_path(path): + if django_escape_uri_path: + return django_escape_uri_path(path) + return escape(path) diff --git a/datatableview/datatables.py b/datatableview/datatables.py index 105bbb91..14edbf7c 100755 --- a/datatableview/datatables.py +++ b/datatableview/datatables.py @@ -9,10 +9,9 @@ except ImportError: pass -from django.db import models -from django.db.models import Count from django.db.models.fields import FieldDoesNotExist from django.template.loader import render_to_string +from django.db.models import QuerySet try: from django.utils.encoding import force_text except ImportError: @@ -30,6 +29,7 @@ FloatColumn, DisplayColumn, CompoundColumn, get_column_for_modelfield) from .utils import (OPTION_NAME_MAP, MINIMUM_PAGE_LENGTH, contains_plural_field, split_terms, resolve_orm_path) +from .cache import DEFAULT_CACHE_TYPE, cache_types, get_cache_key, cache_data, get_cached_data def pretty_name(name): if not name: @@ -37,7 +37,7 @@ def pretty_name(name): return name[0].capitalize() + name[1:] -# Borrowed from the Django forms implementation +# Borrowed from the Django forms implementation def columns_for_model(model, fields=None, exclude=None, labels=None, processors=None, unsortable=None, hidden=None): field_list = [] @@ -81,7 +81,7 @@ def columns_for_model(model, fields=None, exclude=None, labels=None, processors= ) return field_dict -# Borrowed from the Django forms implementation +# Borrowed from the Django forms implementation def get_declared_columns(bases, attrs, with_base_columns=True): """ Create a list of form field instances from the passed in 'attrs', plus any @@ -115,25 +115,32 @@ def get_declared_columns(bases, attrs, with_base_columns=True): return OrderedDict(local_columns) class DatatableOptions(object): + """ + Contains declarable options for a datatable, some of which can be manipuated by subsequent + requests by the user. + """ def __init__(self, options=None): + # Non-mutable; server's declared preference is final self.model = getattr(options, 'model', None) self.columns = getattr(options, 'columns', None) # table headers self.exclude = getattr(options, 'exclude', None) - self.ordering = getattr(options, 'ordering', None) # override to Model._meta.ordering - # self.start_offset = getattr(options, 'start_offset', None) # results to skip ahead - self.page_length = getattr(options, 'page_length', 25) # length of a single result page - # self.search = getattr(options, 'search', None) # client search string self.search_fields = getattr(options, 'search_fields', None) # extra searchable ORM fields self.unsortable_columns = getattr(options, 'unsortable_columns', None) self.hidden_columns = getattr(options, 'hidden_columns', None) # generated, but hidden - + self.labels = getattr(options, 'labels', None) + self.processors = getattr(options, 'processors', None) + self.request_method = getattr(options, 'request_method', 'GET') self.structure_template = getattr(options, 'structure_template', "datatableview/default_structure.html") self.footer = getattr(options, 'footer', False) self.result_counter_id = getattr(options, 'result_counter_id', 'id_count') - # Dictionaries of column names to values - self.labels = getattr(options, 'labels', None) - self.processors = getattr(options, 'processors', None) + # Non-mutable; server behavior customization + self.cache_type = getattr(options, 'cache_type', cache_types.NONE) + self.cache_queryset_count = getattr(options, 'cache_queryset_count', False) + + # Mutable by the request + self.ordering = getattr(options, 'ordering', None) # override to Model._meta.ordering + self.page_length = getattr(options, 'page_length', 25) # length of a single result page default_options = DatatableOptions() @@ -170,6 +177,29 @@ def __new__(cls, name, bases, attrs): label = field.verbose_name column.label = pretty_name(label) + # Normalize declared 'search_fields' to Column instances + if isinstance(opts.search_fields, dict): + # Turn a dictionary of {name: ColumnClass} to just a list of classes. + # If only the column class reference is given instead of an instance, instantiate + # the object first. + search_fields = [] + for name, column in opts.search_fields.items(): + if callable(column): + column = column(sources=[name]) + search_fields.append(column) + opts.search_fields = search_fields + elif opts.search_fields is None: + opts.search_fields = [] + else: + opts.search_fields = list(opts.search_fields) + for i, column in enumerate(opts.search_fields): + # Build a column object + if isinstance(column, six.string_types): + name = column + field = resolve_orm_path(opts.model, name) + column = get_column_for_modelfield(field) + opts.search_fields[i] = column(sources=[name]) + columns.update(declared_columns) else: columns = declared_columns @@ -222,6 +252,9 @@ def configure(self): valid AJAX GET parameters from client modifications to the data they see. """ + if hasattr(self, '_configured'): + return + self.resolve_virtual_columns(*tuple(self.missing_columns)) self.config = self.normalize_config(self._meta.__dict__, self.query_config) @@ -246,6 +279,8 @@ def configure(self): self.columns[column_name].sort_direction = 'desc' if name[0] == '-' else 'asc' self.columns[column_name].index = index + self._configured = True + # Client request configuration mergers def normalize_config(self, config, query_config): """ @@ -261,14 +296,17 @@ def normalize_config(self, config, query_config): if config['unsortable_columns'] is None: config['unsortable_columns'] = [] - for option in ['search', 'start_offset', 'page_length', 'ordering']: - normalizer_f = getattr(self, 'normalize_config_{}'.format(option)) - config[option] = normalizer_f(config, query_config) + config['search'] = self.normalize_config_search(config, query_config) + config['start_offset'] = self.normalize_config_start_offset(config, query_config) + config['page_length'] = self.normalize_config_page_length(config, query_config) + config['ordering'] = self.normalize_config_ordering(config, query_config) + self._ordering_columns = self.ensure_ordering_columns(config['ordering']) return config def normalize_config_search(self, config, query_config): - return query_config.get(OPTION_NAME_MAP['search'], '').strip() + terms_string = query_config.get(OPTION_NAME_MAP['search'], '').strip() + return set(split_terms(terms_string)) def normalize_config_start_offset(self, config, query_config): try: @@ -295,32 +333,25 @@ def normalize_config_page_length(self, config, query_config): return page_length def normalize_config_ordering(self, config, query_config): - # For "n" columns (iSortingCols), the queried values iSortCol_0..iSortCol_n are used as - # column indices to check the values of sSortDir_X and bSortable_X - default_ordering = config['ordering'] - ordering = [] - columns_list = list(self.columns.values()) + if default_ordering is None and config['model']: + default_ordering = config['model']._meta.ordering - try: - num_sorting_columns = int(query_config.get(OPTION_NAME_MAP['num_sorting_columns'], 0)) - except ValueError: - num_sorting_columns = 0 + sort_declarations = [k for k in query_config if re.match(r'^order\[\d+\]\[column\]$', k)] # Default sorting from view or model definition - if num_sorting_columns == 0: + if len(sort_declarations) == 0: return default_ordering - for sort_queue_i in range(num_sorting_columns): + ordering = [] + columns_list = list(self.columns.values()) + + for sort_queue_i in range(len(columns_list)): try: column_index = int(query_config.get(OPTION_NAME_MAP['sort_column'] % sort_queue_i, '')) except ValueError: continue - # Reject out-of-range sort requests - if column_index >= len(columns_list): - continue - column = columns_list[column_index] # Reject requests for unsortable columns @@ -329,7 +360,6 @@ def normalize_config_ordering(self, config, query_config): sort_direction = query_config.get(OPTION_NAME_MAP['sort_column_direction'] % sort_queue_i, None) - sort_modifier = None if sort_direction == 'asc': sort_modifier = '' elif sort_direction == 'desc': @@ -340,10 +370,27 @@ def normalize_config_ordering(self, config, query_config): ordering.append('%s%s' % (sort_modifier, column.name)) - if not ordering and config['model']: - return config['model']._meta.ordering + if not ordering: + return default_ordering return ordering + def ensure_ordering_columns(self, ordering_names): + if ordering_names is None: + return {} + + # Normalize declared 'ordering' to Column instances + ordering_columns = {} + for i, name in enumerate(ordering_names): + if name[0] in '+-': + name = name[1:] + + if name not in self.columns: + field = resolve_orm_path(self.model, name) + column = get_column_for_modelfield(field) + ordering_columns[name] = column(sources=[name]) + + return ordering_columns + def resolve_virtual_columns(self, *names): """ Called with ``*args`` from the Meta.columns declaration that don't match the model's known @@ -372,7 +419,10 @@ def get_ordering_splits(self): for i, name in enumerate(self.config['ordering']): if name[0] in '+-': name = name[1:] - column = self.columns[name] + if name in self.columns: + column = self.columns[name] + else: + column = self._ordering_columns[name] if not column.get_db_sources(self.model): break else: @@ -391,6 +441,130 @@ def get_db_splits(self): return db_fields, virtual_fields # Data retrieval + def will_load_from_cache(self, **kwargs): + """ + Returns a hint for external code concerning the presence of cache data for the given kwargs. + + See :py:meth:`.get_cache_key_kwargs` for information concerning the kwargs you must send for + this hint to be accurate. + """ + cached_data = self.get_cached_data(datatable_class=self.__class__, **kwargs) + return (type(cached_data) is not type(None)) + + def get_cache_key_kwargs(self, view=None, user=None, **kwargs): + """ + Returns the dictionary of kwargs that will be sent to :py:meth:`.get_cache_key` in order to + generate a deterministic cache key. + + ``datatable_class``, ``view``, and ``user`` are returned by default, the user being looked + up on the view's ``request`` attribute. + + Override this classmethod in order to add or remove items from the returned dictionary if + you need a more specific or less specific cache key. + """ + # Try to get user information if 'user' param is missing + if hasattr(view, 'request') and not user: + user = view.request.user + + kwargs.update({ + 'datatable_class': self.__class__, + 'view': view, + 'user': user, + }) + + return kwargs + + def get_cache_key(self, **kwargs): + """ + Returns the full cache key used for object_list data handled by this datatable class. + ``settings.DATATABLEVIEW_CACHE_PREFIX`` will be prepended to this value. + + The kwargs sent guarantee a deterministic cache key between requests. + + ``view`` and ``user`` are special kwargs that the caching system provides by default. The + view instance is inspected for its ``__module__.__name__`` string, and the user for its + ``pk``. + + All other kwargs are hashed and appended to the cache key. + """ + return get_cache_key(**kwargs) + + def get_cached_data(self, **kwargs): + """ Returns object_list data cached for the given kwargs. """ + return get_cached_data(self, **kwargs) + + def cache_data(self, data, **kwargs): + """ Caches object_list data for the given kwargs. """ + cache_data(self, data=data, **kwargs) + + def get_object_list(self): + """ + Returns a cached object list if configured and available. When no caching strategy is + enabled or if the cached item is expired, the original ``object_list`` is returned. + """ + + # Initial object_list from constructor, before filtering or ordering. + object_list = self.object_list + + # Consult cache, if enabled + cache_type = self.config['cache_type'] + if cache_type == cache_types.DEFAULT: + cache_type = DEFAULT_CACHE_TYPE + + if cache_type: + cache_kwargs = self.get_cache_key_kwargs(view=self.view) + cached_data = self.get_cached_data(**cache_kwargs) + + # If no cache is available, simplify and store the original object_list + if cached_data is None: + cached_data = self.prepare_object_list_for_cache(cache_type, object_list) + self.cache_data(cached_data, **cache_kwargs) + + object_list = self.expand_object_list_from_cache(cache_type, cached_data) + + return object_list + + def prepare_object_list_for_cache(self, cache_type, object_list): + """ + Pre-caching hook that must prepare ``object_list`` for the cache using the strategy + indicated by ``cache_type``, which is the table's ``Meta`` + :py:attr:`~datatableview.datatables.Meta.cache_type` value. + + When ``cache_type`` is ``SIMPLE``, the ``object_list`` is returned unmodified. + + When ``PK_LIST`` is used, ``object_list`` is queried for the list of ``pk`` values and those + are returned instead. + """ + data = object_list + + # Create the simplest reproducable query for repeated operations between requests + # Note that 'queryset' cache_type is unhandled so that it passes straight through. + if cache_type == cache_types.PK_LIST: + model = object_list.model + data = tuple(object_list.values_list('pk', flat=True)) + + # Objects in some other type of data structure should be pickable for cache backend + return data + + def expand_object_list_from_cache(self, cache_type, cached_data): + """ + Deserializes the ``cached_data`` fetched from the caching backend, according to the + ``cache_type`` strategy that was used to originally store it. + + When ``cache_type`` is ``SIMPLE``, the ``cached_data`` is returned unmodified, since the + ``object_list`` went into the cache unmodified. + + When ``PK_LIST`` is used, ``cached_data`` is treated as a list of ``pk`` values and is used + to filter the model's default queryset to just those objects. + """ + if cache_type == cache_types.PK_LIST: + # Convert pk list back into queryset + data = self.model.objects.filter(pk__in=cached_data) + else: + # Straight passthrough of cached items + data = cached_data + return data + def _get_current_page(self): """ If page_length is specified in the options or AJAX request, the result list is shortened to @@ -402,6 +576,8 @@ def _get_current_page(self): i_begin = self.config['start_offset'] i_end = self.config['start_offset'] + self.config['page_length'] object_list = self._records[i_begin:i_end] + else: + object_list = self._records return object_list @@ -438,12 +614,51 @@ def populate_records(self): self.configure() self._records = None - objects = self.object_list - objects = self.search(objects) - objects = self.sort(objects) - self._records = objects - self.total_initial_record_count = len(self.object_list) - self.unpaged_record_count = len(self._records) + base_objects = self.get_object_list() + filtered_objects = self.search(base_objects) + filtered_objects = self.sort(filtered_objects) + self._records = filtered_objects + + num_total, num_filtered = self.count_objects(base_objects, filtered_objects) + self.total_initial_record_count = num_total + self.unpaged_record_count = num_filtered + + def count_objects(self, base_objects, filtered_objects): + """ + Calculates object totals for datatable footer. Returns a 2-tuple of counts for, + respectively, the total number of objects and the filtered number of objects. + + Up to two ``COUNT`` queries may be issued. If you already have heavy backend queries, this + might add significant overhead to every ajax fetch, such as keystroke filters. + + If ``Meta.cache_type`` is configured and ``Meta.cache_queryset_count`` is set to True, the + resulting counts will be stored in the caching backend. + """ + + num_total = None + num_filtered = None + + if isinstance(base_objects, QuerySet): + if self.config['cache_queryset_count']: + cache_kwargs = self.get_cache_key_kwargs(view=self.view, __num_total='__num_total') + num_total = self.get_cached_data(**cache_kwargs) + + if num_total is None: + num_total = base_objects.count() + if self.config['cache_queryset_count']: + self.cache_data(num_total, **cache_kwargs) + else: + num_total = len(base_objects) + + if len(self.config['search']) > 0: + if isinstance(filtered_objects, QuerySet): + num_filtered = filtered_objects.count() + else: + num_filtered = len(filtered_objects) + else: + num_filtered = num_total + + return num_total, num_filtered def search(self, queryset): """ Performs db-only queryset searches. """ @@ -459,14 +674,18 @@ def search(self, queryset): columns[name] = self.columns[name] # Global search terms apply to all columns - for term in split_terms(self.config['search']): - # Allow global terms to overwrite identical queries that were single-column + for term in self.config['search']: + # NOTE: Allow global terms to overwrite identical queries that were single-column searches[term] = self.columns.copy() + searches[term].update({None: column for column in self.config['search_fields']}) for term in searches.keys(): term_queries = [] for name, column in searches[term].items(): - search_f = getattr(self, 'search_%s' % (name,), self._search_column) + if name is None: # config.search_fields items + search_f = self._search_column + else: + search_f = getattr(self, 'search_%s' % (name,), self._search_column) q = search_f(column, term) if q is not None: term_queries.append(q) @@ -477,7 +696,7 @@ def search(self, queryset): q = reduce(operator.and_, table_queries) queryset = queryset.filter(q) - return queryset + return queryset.distinct() def _search_column(self, column, terms): """ Requests search queries to be performed against the target column. """ @@ -496,7 +715,10 @@ def sort(self, queryset): if sort_direction == '+': sort_direction = '' name = name[1:] - column = self.columns[name] + if name in self.columns: + column = self.columns[name] + else: + column = self._ordering_columns[name] sources = column.get_sort_fields(self.model) if sources: fields.extend([(sort_direction + source) for source in sources]) @@ -512,13 +734,18 @@ def sort(self, queryset): # Have to sort the whole queryset by hand! object_list = list(object_list) + def flatten(value): + if isinstance(value, (list, tuple)): + return flatten(value[0]) + return value + for name in virtual[::-1]: # stable sorting, top priority sort comes last reverse = False if name[0] in '+-': reverse = (name[0] == '-') name = name[1:] column = self.columns[name] - object_list.sort(key=lambda o: column.value(o)[0], reverse=reverse) + object_list.sort(key=lambda o: flatten(column.value(o)[0]), reverse=reverse) return object_list @@ -547,7 +774,7 @@ def preload_record_data(self, obj): kwargs = {} if self.forward_callback_target and \ - getattr(self.forward_callback_target, 'preload_record_data'): + hasattr(self.forward_callback_target, 'preload_record_data'): kwargs.update(self.forward_callback_target.preload_record_data(obj)) return kwargs @@ -557,7 +784,11 @@ def get_object_pk(self, obj): def get_extra_record_data(self, obj): """ Returns a dictionary of JSON-friendly data sent to the client as ``"DT_RowData"``. """ - return {} + data = {} + if self.forward_callback_target and \ + hasattr(self.forward_callback_target, 'get_extra_record_data'): + data.update(self.forward_callback_target.get_extra_record_data(obj)) + return data def get_record_data(self, obj): """ @@ -592,7 +823,9 @@ def get_record_data(self, obj): if six.PY2 and isinstance(value, str): # not unicode value = value.decode('utf-8') - data[str(i)] = six.text_type(value) + if value is not None: + value = six.text_type(value) + data[str(i)] = value return data def get_column_value(self, obj, column, **kwargs): diff --git a/datatableview/helpers.py b/datatableview/helpers.py index 0ffebab2..8c7250db 100755 --- a/datatableview/helpers.py +++ b/datatableview/helpers.py @@ -10,6 +10,7 @@ """ from functools import partial, wraps +import operator from django import get_version from django.db.models import Model @@ -22,7 +23,7 @@ from .utils import resolve_orm_path, XEDITABLE_FIELD_TYPES -if get_version().split('.') >= ['1', '5']: +if [int(v) for v in get_version().split('.')[0:2]] >= [1, 5]: from django.utils.timezone import localtime else: localtime = None @@ -54,25 +55,34 @@ def keyed_helper(helper): """ @wraps(helper) - def wrapper(instance=None, key=None, *args, **kwargs): - if instance is not None and not key: + def wrapper(instance=None, key=None, attr=None, *args, **kwargs): + if set((instance, key, attr)) == {None}: + # helper was called in place with neither important arg + raise ValueError("If called directly, helper function '%s' requires either a model" + " instance, or a 'key' or 'attr' keyword argument." % helper.__name__) + + if instance is not None: return helper(instance, *args, **kwargs) - elif instance is None: - if key: - # Helper is used directly in the columns declaration. A new callable is - # returned to take the place of a callback. - @wraps(helper) - def helper_wrapper(instance, *args, **kwargs): - return helper(key(instance), *args, **kwargs) - return helper_wrapper + + if key is None and attr is None: + attr = 'self' + + if attr: + if attr == 'self': + key = lambda obj: obj else: - # helper was called in place with neither important arg - raise ValueError("If called directly, helper function '%s' requires either a model" - " instance or a 'key' keyword argument." % helper.__name__) + key = operator.attrgetter(attr) + + # Helper is used directly in the columns declaration. A new callable is + # returned to take the place of a callback. + @wraps(helper) + def helper_wrapper(instance, *args, **kwargs): + return helper(key(instance), *args, **kwargs) + return helper_wrapper + wrapper._is_wrapped = True return wrapper - @keyed_helper def link_to_model(instance, text=None, *args, **kwargs): """ diff --git a/datatableview/static/js/datatableview.js b/datatableview/static/js/datatableview.js index b259edbf..fc67e063 100755 --- a/datatableview/static/js/datatableview.js +++ b/datatableview/static/js/datatableview.js @@ -1,13 +1,111 @@ /* For datatable view */ -var datatableview = { - auto_initialize: false, - defaults: { - "bServerSide": true, - "bPaginate": true - }, - - make_xeditable: function(options) { +var datatableview = (function(){ + var defaultDataTableOptions = { + "serverSide": true, + "paging": true + } + var optionsNameMap = { + 'name': 'name', + 'config-sortable': 'orderable', + 'config-sorting': 'order', + 'config-visible': 'visible', + 'config-searchable': 'searchable' + }; + + var checkGlobalConfirmHook = true; + var autoInitialize = false; + + function initialize($$, opts) { + $$.each(function(){ + var datatable = $(this); + var options = datatableview.getOptions(datatable, opts); + datatable.DataTable(options); + }); + return $$; + } + + function getOptions(datatable, opts) { + /* Reads the options found on the datatable DOM into an object ready to be sent to the + actual DataTable() constructor. Is also responsible for calling the finalizeOptions() + hook to process what is found. + */ + var columnOptions = []; + var sortingOptions = []; + + datatable.find('thead th').each(function(){ + var header = $(this); + var options = {}; + for (var i = 0; i < header[0].attributes.length; i++) { + var attr = header[0].attributes[i]; + if (attr.specified && /^data-/.test(attr.name)) { + var name = attr.name.replace(/^data-/, ''); + var value = attr.value; + + // Typecasting out of string + name = optionsNameMap[name]; + if (/^(true|false)/.test(value.toLowerCase())) { + value = (value === 'true'); + } + + if (name == 'order') { + // This doesn't go in the columnOptions + var sort_info = value.split(','); + sort_info[1] = parseInt(sort_info[1]); + sortingOptions.push(sort_info); + continue; + } + + options[name] = value; + } + } + columnOptions.push(options); + }); + + // Arrange the sorting column requests and strip the priority information + sortingOptions.sort(function(a, b){ return a[0] - b[0] }); + for (var i = 0; i < sortingOptions.length; i++) { + sortingOptions[i] = sortingOptions[i].slice(1); + } + + options = $.extend({}, datatableview.defaults, opts, { + "order": sortingOptions, + "columns": columnOptions, + "pageLength": datatable.attr('data-page-length'), + "infoCallback": function(oSettings, iStart, iEnd, iMax, iTotal, sPre){ + $("#" + datatable.attr('data-result-counter-id')).html(parseInt(iTotal).toLocaleString()); + var infoString = oSettings.oLanguage.sInfo.replace('_START_',iStart).replace('_END_',iEnd).replace('_TOTAL_',iTotal); + if (iMax != iTotal) { + infoString += oSettings.oLanguage.sInfoFiltered.replace('_MAX_',iMax); + } + return infoString; + } + }); + options.ajax = $.extend(options.ajax, { + "url": datatable.attr('data-source-url'), + "type": datatable.attr('data-ajax-method') || 'GET', + "beforeSend": function(request){ + request.setRequestHeader("X-CSRFToken", getCookie('csrftoken')); + } + }); + + options = datatableview.finalizeOptions(datatable, options); + return options; + } + + function finalizeOptions(datatable, options) { + /* Hook for processing all options before sent to actual DataTable() constructor. */ + + // Legacy behavior, will be removed in favor of user providing their own finalizeOptions() + if (datatableview.checkGlobalConfirmHook) { + if (window.confirm_datatable_options !== undefined) { + options = window.confirm_datatable_options(options, datatable); + } + } + return options; + } + + function makeXEditable(options) { var options = $.extend({}, options); if (!options.ajaxOptions) { options.ajaxOptions = {} @@ -15,7 +113,7 @@ var datatableview = { if (!options.ajaxOptions.headers) { options.ajaxOptions.headers = {} } - options.ajaxOptions.headers['X-CSRFToken'] = datatableview.getCookie('csrftoken'); + options.ajaxOptions.headers['X-CSRFToken'] = getCookie('csrftoken'); options.error = function (data) { var response = data.responseJSON; if (response.status == 'error') { @@ -24,14 +122,14 @@ var datatableview = { }); return errors.join('\n'); } - } + }; return function(nRow, mData, iDisplayIndex) { $('td a[data-xeditable]', nRow).editable(options); return nRow; } - }, + } - getCookie: function(name) { + function getCookie(name) { var cookieValue = null; if (document.cookie && document.cookie != '') { var cookies = document.cookie.split(';'); @@ -45,125 +143,34 @@ var datatableview = { } } return cookieValue; - }, - - initialize: function($$, opts) { - if (typeof window.console === "undefined" || typeof window.console.log === "undefined") { - console = { - log: function(){}, - info: function(){} - }; - } - var options_name_map = { - 'config-sortable': 'bSortable', - 'config-sorting': 'aaSorting', - 'config-visible': 'bVisible' - }; - - var template_clear_button = $('Clear'); - - var initialized_datatables = []; - $$.each(function(){ - var datatable = $(this); - var column_options = []; - var sorting_options = []; - - datatable.find('thead th').each(function(){ - var header = $(this); - var options = {}; - for (var i = 0; i < header[0].attributes.length; i++) { - var attr = header[0].attributes[i]; - if (attr.specified && /^data-/.test(attr.name)) { - var name = attr.name.replace(/^data-/, ''); - var value = attr.value; - - // Typecasting out of string - name = options_name_map[name]; - if (/^b/.test(name)) { - value = (value === 'true'); - } - - if (name == 'aaSorting') { - // This doesn't go in the column_options - var sort_info = value.split(','); - sort_info[1] = parseInt(sort_info[1]); - sorting_options.push(sort_info); - continue; - } - - options[name] = value; - } - } - column_options.push(options); - }); - - // Arrange the sorting column requests and strip the priority information - sorting_options.sort(function(a, b){ return a[0] - b[0] }); - for (var i = 0; i < sorting_options.length; i++) { - sorting_options[i] = sorting_options[i].slice(1); - } - - options = $.extend({}, datatableview.defaults, opts, { - "aaSorting": sorting_options, - "aoColumns": column_options, - "sAjaxSource": datatable.attr('data-source-url'), - "iDisplayLength": datatable.attr('data-page-length'), - "fnInfoCallback": function(oSettings, iStart, iEnd, iMax, iTotal, sPre){ - $("#" + datatable.attr('data-result-counter-id')).html(parseInt(iTotal).toLocaleString()); - var infoString = oSettings.oLanguage.sInfo.replace('_START_',iStart).replace('_END_',iEnd).replace('_TOTAL_',iTotal); - if (iMax != iTotal) { - infoString += oSettings.oLanguage.sInfoFiltered.replace('_MAX_',iMax); - } - // /****************************************************************** - // * ########## ### ###### ### - // * ########## ### ## ### ## ### ## - // * ### ### ### ### ## ### ### - // * ### ### ### ### ## ### ### - // * ### ### ### ### ## ### ### - // * ### ###### ###### ###### - // * =============================================================== - // * The string at the bottom of the table showing entries being - // * looked at is always updated, but not always rendered in all - // * browsers *cough cough* Chrome, Safari. - // * This makes it so that results string always updates. - // *****************************************************************/ - // var n = oSettings.aanFeatures.i; - // for (var i = 0, iLen = n.length; i < j; i++) { - // $(n[i]).empty(); - // } - return infoString; - } - }); - try { - options = confirm_datatable_options(options, datatable); - } catch (e) { - - } - - var initialized_datatable = datatable.dataTable(options); - initialized_datatables.push(initialized_datatable[0]); - - try { - initialized_datatable.fnSetFilteringDelay(); - } catch (e) { - console.info("datatable plugin fnSetFilteringDelay not available"); - } + } - var search_input = initialized_datatable.closest('.dataTables_wrapper').find('.dataTables_filter input'); - var clear_button = template_clear_button.clone().click(function(){ - $(this).trigger('clear.datatable', [initialized_datatable]); - return false; - }).bind('clear.datatable', function(){ - search_input.val('').keyup(); - }); - search_input.after(clear_button).after(' '); - }); - return $(initialized_datatables).dataTable(); + var api = { + // values + autoInitialize: autoInitialize, + auto_initialize: undefined, // Legacy name + checkGlobalConfirmHook: checkGlobalConfirmHook, + defaults: defaultDataTableOptions, + + // functions + initialize: initialize, + getOptions: getOptions, + finalizeOptions: finalizeOptions, + makeXEditable: makeXEditable, + make_xeditable: makeXEditable // Legacy name } -} + return api; +})(); $(function(){ - if (datatableview.auto_initialize) { + var shouldInit = null; + if (datatableview.auto_initialize === undefined) { + shouldInit = datatableview.autoInitialize; + } else { + shouldInit = datatableview.auto_initialize + } + + if (shouldInit) { datatableview.initialize($('.datatable')); } }); diff --git a/datatableview/static/js/datatableview.min.js b/datatableview/static/js/datatableview.min.js index 53a56fe2..692247b3 100755 --- a/datatableview/static/js/datatableview.min.js +++ b/datatableview/static/js/datatableview.min.js @@ -1 +1 @@ -var datatableview={auto_initialize:false,defaults:{bServerSide:true,bPaginate:true},make_xeditable:function(options){var options=$.extend({},options);if(!options.ajaxOptions){options.ajaxOptions={}}if(!options.ajaxOptions.headers){options.ajaxOptions.headers={}}options.ajaxOptions.headers["X-CSRFToken"]=datatableview.getCookie("csrftoken");options.error=function(data){var response=data.responseJSON;if(response.status=="error"){var errors=$.map(response.form_errors,function(errors,field){return errors.join("\n")});return errors.join("\n")}};return function(nRow,mData,iDisplayIndex){$("td a[data-xeditable]",nRow).editable(options);return nRow}},getCookie:function(name){var cookieValue=null;if(document.cookie&&document.cookie!=""){var cookies=document.cookie.split(";");for(var i=0;iClear');var initialized_datatables=[];$$.each(function(){var datatable=$(this);var column_options=[];var sorting_options=[];datatable.find("thead th").each(function(){var header=$(this);var options={};for(var i=0;i diff --git a/datatableview/templates/datatableview/default_structure.html b/datatableview/templates/datatableview/default_structure.html index e908a464..6cd0bd23 100755 --- a/datatableview/templates/datatableview/default_structure.html +++ b/datatableview/templates/datatableview/default_structure.html @@ -1,4 +1,4 @@ - diff --git a/datatableview/templates/datatableview/legacy_structure.html b/datatableview/templates/datatableview/legacy_structure.html index 71d1d8d0..b95f3727 100755 --- a/datatableview/templates/datatableview/legacy_structure.html +++ b/datatableview/templates/datatableview/legacy_structure.html @@ -1,4 +1,4 @@ -
    diff --git a/datatableview/tests/__init__.py b/datatableview/tests/__init__.py index 71645ca4..627d8c34 100755 --- a/datatableview/tests/__init__.py +++ b/datatableview/tests/__init__.py @@ -3,3 +3,5 @@ from .test_views import * from .test_utils import * from .test_helpers import * +from .test_columns import * +from .test_datatables import * diff --git a/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_17.json b/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_17.json new file mode 100755 index 00000000..af550550 --- /dev/null +++ b/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_17.json @@ -0,0 +1,99 @@ +[ + { "model": "datatableview.Blog", "pk": 1, "fields": { + "name": "First Blog", + "tagline": "First and finest" + }}, + { "model": "datatableview.Blog", "pk": 2, "fields": { + "name": "Second Blog", + "tagline": "Last but not least" + }}, + + { "model": "datatableview.Author", "pk": 1, "fields": { + "name": "Tim Valenta", + "email": "tvalenta@pivotalenergysolutions.com" + }}, + + { "model": "datatableview.Author", "pk": 2, "fields": { + "name": "The Cow", + "email": "moo@aol.com" + }}, + + { "model": "datatableview.Entry", "pk": 1, "fields": { + "blog": 1, + "headline": "Hello World", + "body_text": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + "pub_date": "2013-01-01", + "mod_date": "2013-01-02", + "n_comments": 2, + "n_pingbacks": 0, + "rating": 0, + "authors": [1], + "status": 1, + "is_published": true + }}, + { "model": "datatableview.Entry", "pk": 2, "fields": { + "blog": 1, + "headline": "Headline", + "body_text": "", + "pub_date": "2013-02-01", + "mod_date": "2013-02-02", + "n_comments": 0, + "n_pingbacks": 5, + "rating": 0, + "authors": [2], + "status": 1, + "is_published": true + }}, + { "model": "datatableview.Entry", "pk": 3, "fields": { + "blog": 1, + "headline": "Third Headline", + "body_text": "", + "pub_date": "2013-01-01", + "mod_date": "2013-01-02", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [1, 2], + "status": 1, + "is_published": true + }}, + { "model": "datatableview.Entry", "pk": 4, "fields": { + "blog": 1, + "headline": "Fourth Headline", + "body_text": "", + "pub_date": "2013-01-01", + "mod_date": "2013-01-02", + "n_comments": 273, + "n_pingbacks": 1017, + "rating": 0, + "authors": [1, 2], + "status": 0, + "is_published": false + }}, + { "model": "datatableview.Entry", "pk": 5, "fields": { + "blog": 1, + "headline": "Fifth Headline", + "body_text": "", + "pub_date": "2013-01-01", + "mod_date": "2013-01-02", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [1, 2], + "status": 0, + "is_published": false + }}, + { "model": "datatableview.Entry", "pk": 6, "fields": { + "blog": 2, + "headline": "Sixth Headline", + "body_text": "", + "pub_date": "2013-01-01", + "mod_date": "2013-01-02", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [1, 2], + "status": 1, + "is_published": true + }} +] diff --git a/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_modern.json b/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_modern.json index c4765993..1191676f 100755 --- a/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_modern.json +++ b/datatableview/tests/example_project/example_project/example_app/fixtures/initial_data_modern.json @@ -1,24 +1,24 @@ [ - { "model": "datatableview.Blog", "pk": 1, "fields": { + { "model": "example_app.Blog", "pk": 1, "fields": { "name": "First Blog", "tagline": "First and finest" }}, - { "model": "datatableview.Blog", "pk": 2, "fields": { + { "model": "example_app.Blog", "pk": 2, "fields": { "name": "Second Blog", "tagline": "Last but not least" }}, - { "model": "datatableview.Author", "pk": 1, "fields": { + { "model": "example_app.Author", "pk": 1, "fields": { "name": "Tim Valenta", "email": "tvalenta@pivotalenergysolutions.com" }}, - { "model": "datatableview.Author", "pk": 2, "fields": { + { "model": "example_app.Author", "pk": 2, "fields": { "name": "The Cow", "email": "moo@aol.com" }}, - { "model": "datatableview.Entry", "pk": 1, "fields": { + { "model": "example_app.Entry", "pk": 1, "fields": { "blog": 1, "headline": "Hello World", "body_text": "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", @@ -28,9 +28,10 @@ "n_pingbacks": 0, "rating": 0, "authors": [1], - "status": 1 + "status": 1, + "is_published": true }}, - { "model": "datatableview.Entry", "pk": 2, "fields": { + { "model": "example_app.Entry", "pk": 2, "fields": { "blog": 1, "headline": "Headline", "body_text": "", @@ -40,9 +41,10 @@ "n_pingbacks": 5, "rating": 0, "authors": [2], - "status": 1 + "status": 1, + "is_published": true }}, - { "model": "datatableview.Entry", "pk": 3, "fields": { + { "model": "example_app.Entry", "pk": 3, "fields": { "blog": 1, "headline": "Third Headline", "body_text": "", @@ -52,9 +54,10 @@ "n_pingbacks": 0, "rating": 0, "authors": [1, 2], - "status": 1 + "status": 1, + "is_published": true }}, - { "model": "datatableview.Entry", "pk": 4, "fields": { + { "model": "example_app.Entry", "pk": 4, "fields": { "blog": 1, "headline": "Fourth Headline", "body_text": "", @@ -64,9 +67,10 @@ "n_pingbacks": 1017, "rating": 0, "authors": [1, 2], - "status": 0 + "status": 0, + "is_published": false }}, - { "model": "datatableview.Entry", "pk": 5, "fields": { + { "model": "example_app.Entry", "pk": 5, "fields": { "blog": 1, "headline": "Fifth Headline", "body_text": "", @@ -76,9 +80,10 @@ "n_pingbacks": 0, "rating": 0, "authors": [1, 2], - "status": 0 + "status": 0, + "is_published": false }}, - { "model": "datatableview.Entry", "pk": 6, "fields": { + { "model": "example_app.Entry", "pk": 6, "fields": { "blog": 2, "headline": "Sixth Headline", "body_text": "", @@ -88,6 +93,7 @@ "n_pingbacks": 0, "rating": 0, "authors": [1, 2], - "status": 1 + "status": 1, + "is_published": true }} ] diff --git a/datatableview/tests/example_project/example_project/example_app/migrations/0001_initial.py b/datatableview/tests/example_project/example_project/example_app/migrations/0001_initial.py new file mode 100755 index 00000000..7b1f82cb --- /dev/null +++ b/datatableview/tests/example_project/example_project/example_app/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-10-05 00:54 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('email', models.EmailField(max_length=254)), + ], + ), + migrations.CreateModel( + name='Blog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('tagline', models.TextField()), + ], + ), + migrations.CreateModel( + name='Entry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('headline', models.CharField(max_length=255)), + ('body_text', models.TextField()), + ('pub_date', models.DateField()), + ('mod_date', models.DateField()), + ('n_comments', models.IntegerField()), + ('n_pingbacks', models.IntegerField()), + ('rating', models.IntegerField()), + ('status', models.IntegerField(choices=[(0, b'Draft'), (1, b'Published')])), + ('authors', models.ManyToManyField(to='example_app.Author')), + ('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='example_app.Blog')), + ], + ), + ] diff --git a/datatableview/tests/example_project/example_project/example_app/migrations/0002_entry_is_published.py b/datatableview/tests/example_project/example_project/example_app/migrations/0002_entry_is_published.py new file mode 100755 index 00000000..db110122 --- /dev/null +++ b/datatableview/tests/example_project/example_project/example_app/migrations/0002_entry_is_published.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-10-05 02:02 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('example_app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='entry', + name='is_published', + field=models.BooleanField(default=False), + ), + ] diff --git a/datatableview/tests/example_project/example_project/example_app/migrations/__init__.py b/datatableview/tests/example_project/example_project/example_app/migrations/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/datatableview/tests/example_project/example_project/example_app/models.py b/datatableview/tests/example_project/example_project/example_app/models.py index 2c3a20b4..20e77e37 100755 --- a/datatableview/tests/example_project/example_project/example_app/models.py +++ b/datatableview/tests/example_project/example_project/example_app/models.py @@ -39,6 +39,7 @@ class Entry(models.Model): (0, "Draft"), (1, "Published"), )) + is_published = models.BooleanField(default=False) def __unicode__(self): return self.headline diff --git a/datatableview/tests/example_project/example_project/example_app/templates/base.html b/datatableview/tests/example_project/example_project/example_app/templates/base.html index 73bdf675..3fa2c725 100755 --- a/datatableview/tests/example_project/example_project/example_app/templates/base.html +++ b/datatableview/tests/example_project/example_project/example_app/templates/base.html @@ -86,6 +86,7 @@
  • + diff --git a/datatableview/tests/example_project/example_project/example_app/templates/demos/col_reorder.html b/datatableview/tests/example_project/example_project/example_app/templates/demos/col_reorder.html index f197d0c7..c00f9176 100755 --- a/datatableview/tests/example_project/example_project/example_app/templates/demos/col_reorder.html +++ b/datatableview/tests/example_project/example_project/example_app/templates/demos/col_reorder.html @@ -5,8 +5,8 @@ - - + +