Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion TODO
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ Long Term

TODO: build xml file for exporting permissions to Google SDC for secure google spreadsheet importing
TODO: Add oauth for outside report gathering/pulling
TODO: Add support for embedded graphs and charts (maybe alternate output format that allows embedding?)
TODO: Create emailer model that allows you to specify a certain report+filter and have it emailed daily
100 changes: 92 additions & 8 deletions reportengine/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"""Reports base class. This reports module trys to provide an ORM agnostic reports engine that will allow nice reports to be generated and exportable in a variety of formats. It seeks to be easy to use with querysets, raw SQL, or pure python. An additional goal is to have the reports be managed by model instances as well (e.g. a generic SQL based report that can be done in the backend).

"""
import collections
import datetime
from functools import wraps

from django import forms
from django.db import models
from django.db.models.fields.related import RelatedField
from django.db.models.fields import FieldDoesNotExist

from filtercontrols import *
from outputformats import *
import datetime

# Pulled from vitalik's Django-reporting
def get_model_field(model, name):
Expand All @@ -23,11 +27,55 @@ def get_lookup_field(model, original, lookup):
next_lookup = '__'.join(parts[1:])
return get_lookup_field(rel_model, original, next_lookup)

def rearrange_columns(*column_indices):
u'''Returns decorator for chart callable that rearranges presence and ordering of schema's and data's items.

Without arguments, returns identity decorator'''
# See charts documentation below

def columns_decorator(chart_callable):
if not column_indices:
return chart_callable

# This is a function even if chart_callable was a class, but that doesn't bother us.
@wraps(chart_callable)
def decorated_callable(schema, data, *args, **kwargs):
rearranged_schema = [schema[j] for j in column_indices]
rearranged_data = [[row[j] for j in column_indices] for row in data]
return chart_callable(rearranged_schema, rearranged_data, *args, **kwargs)
return decorated_callable
return columns_decorator

class ReportMetaclass(type):
def __new__(cls, *args, **kwargs):
report_class = super(ReportMetaclass, cls).__new__(cls, *args, **kwargs)

# If columns are not specified, fill them with labels; also handle the opposite situation.
report_class.columns = report_class.columns or [(label,) for label in report_class.labels]
report_class.labels = report_class.labels or [column[0] for column in report_class.columns]

return report_class

class Report(object):
__metaclass__ = ReportMetaclass

verbose_name="Abstract Report"
namespace = "Default"
slug ="base"
labels = None

# NOTE: you should specify either "columns" or "labels", not both.
# CONSIDER: should labels be removed?

# ("foo", "bar", "baz")
labels = []

# An iterable of tuples (column_id, data_type, label, options), where data type is one of
# 'string' 'number' 'boolean' 'date' 'datetime' 'timeofday' and options is a dictionary
# data_type, label and options are optional.
#
# This is designed to reflect https://developers.google.com/chart/interactive/docs/dev/gviz_api_lib#describeschema
columns = []

per_page=100
can_show_all=True
output_formats=[AdminOutputFormat(),CSVOutputFormat()]
Expand All @@ -37,9 +85,47 @@ class Report(object):
date_field = None # if specified will lookup for this date field. .this is currently limited to queryset based lookups
default_mask = {} # a dict of filter default values. Can be callable

# TODO add charts = [ {'name','type e.g. bar','data':(0,1,3) cols in table}]
# then i can auto embed the charts at the top of the report based upon that data..
# Charts
#
# This should be an iterable of tuples where first element is an iterable of callables that accept data schema
# (formatted like columns attribute) and data rows. This callables should return chart objects.
# If you don't know where to obtain such callables, take a look at https://github.com/KrzysiekJ/gchart
#
# Second element is an iterable of column indices that should be included in the chart (order matters).
# If it is empty, all columns will be included.
#
# If third element does not evaluate to False, it should be either an iterable of output format classes or a callable.
#
# For example, if you want to embed some charts in AdminOutputFormat, you can write:
# charts = (
# ([foo_chart, bar_chart], (), (AdminOutputFormat,)),
# )
# or
# charts = (
# ([foo_chart, bar_chart], (), lambda output_format: isinstance(output_format, AdminOutputFormat)),
# )
#
# Charts will additionally be filtered by output format's can_embed method
charts = []

def get_charts(self, output_format):
u'''Returns chart callables that can be used by particular output format.'''

return sum(
[
[
rearrange_columns(*column_indices)(chart) for chart in charts if (
not chart_filter or
callable(chart_filter) and chart_filter(chart) or
isinstance(chart_filter, collections.Iterable) and any(isinstance(output_format, output_format_class) for output_format_class in chart_filter)
) and
output_format.can_embed(chart) # CONSIDER: maybe can_embed should be called internally by output format?
]
for charts, column_indices, chart_filter in self.charts
],
[]
)

def get_default_mask(self):
"""Builds default mask. The filter is merged with this to create the filter for the report. Items can be callable and will be resolved when called here (which should be at view time)."""
m={}
Expand All @@ -58,7 +144,6 @@ def get_rows(self,filters={},order_by=None):
"""takes in parameters and pumps out an iterable of iterables for rows/cols, an list of tuples with (name/value) for the aggregates"""
raise NotImplementedError("Subclass should return [],(('total',0),)")


# CONSIDER do this by day or by month? month seems most efficient in terms of optimizing queries
def get_monthly_aggregates(self,year,month):
"""Called when assembling a calendar view of reports. This will be queried for every day, so should be quick"""
Expand All @@ -67,7 +152,7 @@ def get_monthly_aggregates(self,year,month):

class QuerySetReport(Report):
# TODO make labels more addressable. now fixed to fields in model. what happens with relations?
labels = None
labels = []
queryset = None
list_filter = []

Expand Down Expand Up @@ -103,7 +188,6 @@ def get_rows(self,filters={},order_by=None):

class ModelReport(QuerySetReport):
model = None


def __init__(self):
super(ModelReport, self).__init__()
Expand All @@ -112,7 +196,7 @@ def __init__(self):
def get_queryset(self, filters, order_by, queryset=None):
if queryset is None and self.queryset is None:
queryset = self.model.objects.all()
return super(ModelReport).get_queryset(filters, order_by, queryset)
return super(ModelReport, self).get_queryset(filters, order_by, queryset)

class SQLReport(Report):
rows_sql=None # sql statement with named parameters in python syntax (e.g. "%(age)s" )
Expand Down
6 changes: 3 additions & 3 deletions reportengine/filtercontrols.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,21 @@ def get_fields(self):
return {self.field_name:forms.CharField(label=self.label or self.field_name,required=False)}

# Pulled from django.contrib.admin.filterspecs
@classmethod
def register(cls, test, factory, datatype):
cls.filter_controls.append((test, factory, datatype))
register = classmethod(register)

@classmethod
def create_from_modelfield(cls, f, field_name, label=None):
for test, factory, datatype in cls.filter_controls:
if test(f):
return factory(field_name,label)
create_from_modelfield = classmethod(create_from_modelfield)

@classmethod
def create_from_datatype(cls, datatype, field_name, label=None):
for test, factory, dt in cls.filter_controls:
if dt == datatype:
return factory(field_name,label)
create_from_datatype = classmethod(create_from_datatype)

FilterControl.register(lambda m: isinstance(m,models.CharField),FilterControl,"char")

Expand Down
9 changes: 9 additions & 0 deletions reportengine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ def build_report(self):
self.aggregates = aggregates
self.completion_timestamp = datetime.datetime.now()
self.save()

def get_charts(self, output_format, data):
report = self.get_report()
return [chart_class(report.columns, data) for chart_class in report.get_charts(output_format)]

def get_task_function(self):
from tasks import async_report
Expand All @@ -115,6 +119,11 @@ class ReportRequestRow(models.Model):
row_number = models.PositiveIntegerField()
data = JSONField(datatype=list)

def __iter__(self):
return iter(self.data)
def __getitem__(self, key):
return self.data[key]

class ReportRequestExport(AbstractScheduledTask):
report_request = models.ForeignKey(ReportRequest, related_name='exports')
format = models.CharField(max_length=10)
Expand Down
8 changes: 8 additions & 0 deletions reportengine/outputformats.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def generate_output(self, context, output):
def get_response(self,context,request):
raise NotImplemented("Use a subclass of OutputFormat.")

def can_embed(self, chart):
# You will probably want to employ duck typing here.
return False

class AdminOutputFormat(OutputFormat):
verbose_name="Admin Report"
slug="admin"
Expand All @@ -40,6 +44,10 @@ def get_response(self,context,request):
return render_to_response('reportengine/report.html', context,
context_instance=RequestContext(request))

def can_embed(self, chart):
# Actually, initalize_html is also used in template but we don't consider it as required attribute.
return hasattr(chart, 'render_html')

class CSVOutputFormat(OutputFormat):
verbose_name="CSV (comma separated value)"
slug="csv"
Expand Down
7 changes: 7 additions & 0 deletions reportengine/templates/reportengine/report.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<script type="text/javascript" src="{% admin_media_prefix %}js/core.js"></script>
<script type="text/javascript" src="{% admin_media_prefix %}js/calendar.js"></script>
<script type="text/javascript" src="{% admin_media_prefix %}js/admin/DateTimeShortcuts.js"></script>
{% for chart in charts %}
{{ chart.initialize_html }}
{% endfor %}
{% endblock %}

{% block bodyclass %}change-list{% endblock %}
Expand Down Expand Up @@ -63,6 +66,10 @@ <h3>{% trans "Alternate Formats" %}</h3>
</div>
{% endblock %}

{% for chart in charts %}
{{ chart.render_html }}
{% endfor %}

<h3>Data</h3>
<table>
<thead>
Expand Down
27 changes: 17 additions & 10 deletions reportengine/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ def get_report_request(self):
self.report_request = ReportRequest.objects.get(token=token)
self.report = self.report_request.get_report()
ReportRequest.objects.filter(pk=self.report_request.pk).update(viewed_on=datetime.datetime.now())

def get_queryset(self):
return ReportRowQuery(self.report_request.rows.all())

Expand Down Expand Up @@ -208,14 +208,29 @@ def get_context_data(self, **kwargs):
"aggregates":self.report_request.aggregates,
"cl":self.get_changelist(data),
'report_request':self.report_request,
'charts': self.report_request.get_charts(self.output_format, self.object_list),
"urlparams":urlencode(self.report_request.params)})
return data

def get_output_format(self):
outputformat = None
output = self.kwargs.get('output', 'admin')
if output:
for of in self.report.output_formats:
if of.slug == output:
outputformat=of
if not outputformat:
outputformat = self.report.output_formats[0]
self.output_format = outputformat
return outputformat


def get(self, request, *args, **kwargs):
try:
self.get_report_request()
except ReportRequest.DoesNotExist:
raise Http404()
self.get_output_format()
status = self.check_report_status()
if 'error' in status: #there was an error, try recreating the report
#CONSIDER add max retries
Expand All @@ -232,15 +247,7 @@ def get(self, request, *args, **kwargs):
self.object_list = self.get_queryset()
kwargs['object_list'] = self.object_list
data = self.get_context_data(**kwargs)
outputformat = None
output = kwargs.get('output', 'admin')
if output:
for of in self.report.output_formats:
if of.slug == output:
outputformat=of
if not outputformat:
outputformat = self.report.output_formats[0]
return outputformat.get_response(data, request)
return self.output_format.get_response(data, request)

view_report = never_cache(staff_member_required(ReportView.as_view()))

Expand Down