diff --git a/TODO b/TODO index 915895b..b355e31 100644 --- a/TODO +++ b/TODO @@ -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 diff --git a/reportengine/base.py b/reportengine/base.py index 6c7ee97..cf29454 100755 --- a/reportengine/base.py +++ b/reportengine/base.py @@ -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): @@ -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()] @@ -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={} @@ -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""" @@ -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 = [] @@ -103,7 +188,6 @@ def get_rows(self,filters={},order_by=None): class ModelReport(QuerySetReport): model = None - def __init__(self): super(ModelReport, self).__init__() @@ -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" ) diff --git a/reportengine/filtercontrols.py b/reportengine/filtercontrols.py index c61a1a6..635a574 100755 --- a/reportengine/filtercontrols.py +++ b/reportengine/filtercontrols.py @@ -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") diff --git a/reportengine/models.py b/reportengine/models.py index f241794..aee6988 100644 --- a/reportengine/models.py +++ b/reportengine/models.py @@ -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 @@ -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) diff --git a/reportengine/outputformats.py b/reportengine/outputformats.py index 7051bb0..65d0ebe 100755 --- a/reportengine/outputformats.py +++ b/reportengine/outputformats.py @@ -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" @@ -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" diff --git a/reportengine/templates/reportengine/report.html b/reportengine/templates/reportengine/report.html index 7538e7d..745909b 100755 --- a/reportengine/templates/reportengine/report.html +++ b/reportengine/templates/reportengine/report.html @@ -11,6 +11,9 @@ + {% for chart in charts %} + {{ chart.initialize_html }} + {% endfor %} {% endblock %} {% block bodyclass %}change-list{% endblock %} @@ -63,6 +66,10 @@