├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO ├── doc ├── Makefile ├── conf.py └── index.rst ├── example ├── .gitignore ├── __init__.py ├── example_reports │ ├── __init__.py │ ├── models.py │ ├── reports.py │ ├── tests.py │ └── views.py ├── manage.py ├── settings.py └── urls.py ├── reportengine ├── __init__.py ├── base.py ├── filtercontrols.py ├── jsonfield.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── cleanup_stale_reports.py │ │ └── generate_report.py ├── models.py ├── outputformats.py ├── settings.py ├── tasks.py ├── templates │ └── reportengine │ │ ├── async_wait.html │ │ ├── calendar.html │ │ ├── calendar_day.html │ │ ├── calendar_month.html │ │ ├── list.html │ │ ├── report.html │ │ └── request_report.html ├── tests.py ├── urls.py └── views.py ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── models.py ├── reports.py ├── tests.py ├── urls.py ├── utils.py └── views.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pye 3 | *.egg-info 4 | build 5 | dist 6 | bin 7 | *.db 8 | include 9 | lib 10 | .Python 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Nikolaj Baer and Cuker Interactive 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Cuker Interactive nor Nikolaj Baer may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include reportengine/templates * 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ReportEngine (for Django 1.1+) 2 | ============================== 3 | 4 | by Nikolaj Baer for Web Cube CMS [http://www.webcubecms.com] 5 | ------------------------------------------------------------ 6 | 7 | *Version: Beta* 8 | 9 | Overview 10 | -------- 11 | 12 | Inspired by vitalik's django-reporting 13 | [http://code.google.com/p/django-reporting], ReportEngine seeks to make 14 | something less tied to the ORM to allow for those many instances where complex 15 | reports require some direct sql, or pure python code. 16 | 17 | It allows you to add new reports that either feed directly from a model, do 18 | their own custom SQL or run anything in python. Reports can be outputted into 19 | various formats, and you can specify new formats. Reports can have extensible 20 | filter controls, powered by a form (with premade ones for queryset and model 21 | based reports). 22 | 23 | Example 24 | ------- 25 | 26 | Take a look at the sample project and reports in the example folder. To run it 27 | you need to have Django 1.3 or later and reportengine on your PYTHONPATH. 28 | 29 | 30 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Tasks 2 | ----- 3 | 4 | Short Term 5 | ++++++++++ 6 | 7 | TODO: build SQLReport that is based on instances configured in backend 8 | TODO: add manage.py command that generates specified reports and puts them in a certain spot 9 | TODO: add table row sorting 10 | TODO: figure out per page aggregates (right now that is not accessible in get_rows) 11 | TODO: maybe allow fields in queryset report to be callable on the model? 12 | TODO: look into group bys, try an example 13 | TODO: create an intuitive filter system for non-queryset based reports 14 | TODO: make today type redirects and add date_field specifier (almost done) 15 | TODO: add fine-grained permissions per report 16 | TODO: add template tag for embeddable reports 17 | TODO: add per day (or month) aggregate date_field capabilities 18 | TODO: setup a mechanism to have an "offline" report. You click to generate the report, it gets queued, and a queue processor hits it. That way multiple requests for the same report are handled outside of the actual apache processes 19 | 20 | Long Term 21 | +++++++++ 22 | 23 | TODO: build xml file for exporting permissions to Google SDC for secure google spreadsheet importing 24 | TODO: Add oauth for outside report gathering/pulling 25 | TODO: Add support for embedded graphs and charts (maybe alternate output format that allows embedding?) 26 | TODO: Create emailer model that allows you to specify a certain report+filter and have it emailed daily 27 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoReportengine.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoReportengine.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoReportengine" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoReportengine" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Reportengine documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jan 1 15:59:51 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath('..')) 20 | os.environ.update({"DJANGO_SETTINGS_MODULE":"runtests"}) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'Django Reportengine' 45 | copyright = u'2013, Kevin Mooney' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = '0.01a' 53 | # The full version, including alpha/beta/rc tags. 54 | release = '0.01a' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'default' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'DjangoReportenginedoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'DjangoReportengine.tex', u'Django Reportengine Documentation', 188 | u'Kevin Mooney', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'djangoreportengine', u'Django Reportengine Documentation', 218 | [u'Kevin Mooney'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'DjangoReportengine', u'Django Reportengine Documentation', 232 | u'Kevin Mooney', 'DjangoReportengine', 'One line description of project.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Reportengine documentation master file, created by 2 | sphinx-quickstart on Tue Jan 1 15:59:51 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django Reportengine 7 | =============================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :glob: . 14 | 15 | Introduction 16 | --------------- 17 | 18 | Django Reportengine is a report framework that makes it easy to 19 | create reports based on Django querysets, or to create your own 20 | highly customized reports. 21 | 22 | It can generate CVS, XLS, XML or Django Admin Interface results 23 | for reports. 24 | 25 | It can also asynchronously create reports and direct the user to 26 | them when the report is complete. 27 | 28 | 29 | 30 | Getting Started 31 | ---------------- 32 | 33 | The easiest way to get started with ReportEngine is to install 34 | from pypi:: 35 | 36 | pip install django-reportengine 37 | 38 | To do anything useful with Report Engine, you'll need to extend 39 | its base reports. The simplest of these reports is a model report:: 40 | 41 | from reportengine.base import ModelReport 42 | from myapp.models import MyModel 43 | 44 | class MyReport(ModelReport): 45 | model = MyModel 46 | 47 | 48 | This will return a report of all objects of type `MyModel.` 49 | 50 | Please see the `Class Reference` for details about implementing more 51 | complex reports. 52 | 53 | 54 | Indices and tables 55 | ================== 56 | 57 | * :ref:`genindex` 58 | * :ref:`modindex` 59 | * :ref:`search` 60 | 61 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | reportengine 2 | test.db 3 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/example/__init__.py -------------------------------------------------------------------------------- /example/example_reports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/example/example_reports/__init__.py -------------------------------------------------------------------------------- /example/example_reports/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /example/example_reports/reports.py: -------------------------------------------------------------------------------- 1 | import reportengine 2 | from django.contrib.auth.models import User 3 | from reportengine.filtercontrols import StartsWithFilterControl 4 | from reportengine.outputformats import * 5 | 6 | class UserReport(reportengine.ModelReport): 7 | """An example of a model report""" 8 | verbose_name = "User Report" 9 | slug = "user-report" 10 | namespace = "system" 11 | description = "Listing of all users in the system" 12 | labels = ('username','is_active','email','first_name','last_name','date_joined') 13 | list_filter=['is_active','date_joined',StartsWithFilterControl('username'),'groups'] 14 | date_field = "date_joined" # Allows auto filtering by this date 15 | model=User 16 | per_page = 500 17 | 18 | reportengine.register(UserReport) 19 | 20 | class ActiveUserReport(reportengine.QuerySetReport): 21 | """ An example of a queryset report. """ 22 | verbose_name="Active User Report" 23 | slug = "active-user-report" 24 | namespace = "system" 25 | per_page=10 26 | labels = ('username','email','first_name','last_name','date_joined') 27 | queryset=User.objects.filter(is_active=True) 28 | 29 | reportengine.register(ActiveUserReport) 30 | 31 | class AppsReport(reportengine.Report): 32 | """An Example report that is pure python, just returning a list""" 33 | verbose_name="Installed Apps" 34 | namespace = "system" 35 | slug = "apps-report" 36 | labels = ('app_name',) 37 | per_page = 0 38 | output_formats = [AdminOutputFormat(),XMLOutputFormat(root_tag="apps",row_tag="app")] 39 | 40 | def get_rows(self,filters={},order_by=None): 41 | from django.conf import settings 42 | # maybe show off by pulling active content type models for each app? 43 | # from django.contrib.contenttypes.models import ContentType 44 | apps=[[a,] for a in settings.INSTALLED_APPS] 45 | 46 | if order_by: 47 | # TODO add sorting based on label? 48 | apps.sort() 49 | total=len(apps) 50 | return apps,(("total",total),) 51 | 52 | reportengine.register(AppsReport) 53 | 54 | class AdminActivityReport(reportengine.DateSQLReport): 55 | row_sql="""select username,user_id,count(*),min(action_time),max(action_time) 56 | from django_admin_log 57 | inner join auth_user on auth_user.id = django_admin_log.user_id 58 | where is_staff = 1 59 | and action_time >= '%(date__gte)s' 60 | and action_time < '%(date__lt)s' 61 | group by user_id; 62 | """ 63 | aggregate_sql="""select avg(count) as average,max(count) as max,min(count) as min 64 | from ( 65 | select count(user_id) as count 66 | from django_admin_log 67 | where action_time >= '%(date__gte)s' 68 | and action_time < '%(date__lt)s' 69 | group by user_id 70 | )""" 71 | # TODO adding parameters to the sql report is.. hard. 72 | #query_params = [("username","Username","char")] 73 | namespace="administration" 74 | labels = ('username','user id','actions','oldest','latest') 75 | verbose_name="Admin Activity Report" 76 | slug="admin-activity" 77 | 78 | reportengine.register(AdminActivityReport) 79 | 80 | -------------------------------------------------------------------------------- /example/example_reports/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates two different styles of tests (one doctest and one 3 | unittest). These will both pass when you run "manage.py test". 4 | 5 | Replace these with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | class SimpleTest(TestCase): 11 | def test_basic_addition(self): 12 | """ 13 | Tests that 1 + 1 always equals 2. 14 | """ 15 | self.failUnlessEqual(1 + 1, 2) 16 | 17 | __test__ = {"doctest": """ 18 | Another way to test that 1 + 1 is equal to 2. 19 | 20 | >>> 1 + 1 == 2 21 | True 22 | """} 23 | 24 | -------------------------------------------------------------------------------- /example/example_reports/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for example project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@domain.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASE_ENGINE = 'sqlite3' # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 13 | DATABASE_NAME = 'test.db' # Or path to database file if using sqlite3. 14 | 15 | TIME_ZONE = 'America/Chicago' 16 | 17 | LANGUAGE_CODE = 'en-us' 18 | 19 | SITE_ID = 1 20 | 21 | USE_I18N = True 22 | 23 | MEDIA_ROOT = '' 24 | 25 | MEDIA_URL = '' 26 | 27 | ADMIN_MEDIA_PREFIX = '/media/' 28 | 29 | SECRET_KEY = 's!ro3_a+b=j^0wvy7-r5frvd)ls6z61!2qs-^&-v&!5-9uns@-' 30 | 31 | TEMPLATE_LOADERS = ( 32 | 'django.template.loaders.filesystem.load_template_source', 33 | 'django.template.loaders.app_directories.load_template_source', 34 | # 'django.template.loaders.eggs.load_template_source', 35 | ) 36 | 37 | MIDDLEWARE_CLASSES = ( 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.contrib.sessions.middleware.SessionMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | ) 42 | 43 | ROOT_URLCONF = 'example.urls' 44 | 45 | INSTALLED_APPS = ( 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.sites', 50 | 'django.contrib.admin', 51 | 'reportengine', 52 | 'example_reports', 53 | 'djcelery', 54 | 'djkombu', 55 | ) 56 | 57 | 58 | ASYNC_REPORTS=True 59 | BROKER_TRANSPORT = "djkombu.transport.DatabaseTransport" 60 | 61 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | # Be sure to autodiscover the reports 7 | import reportengine 8 | reportengine.autodiscover() 9 | 10 | urlpatterns = patterns('', 11 | (r'^$', 'django.views.generic.simple.redirect_to',{"url":"/reports/"}), 12 | (r'^admin/', include(admin.site.urls)), 13 | (r'^reports/', include('reportengine.urls')), 14 | ) 15 | 16 | -------------------------------------------------------------------------------- /reportengine/__init__.py: -------------------------------------------------------------------------------- 1 | import imp 2 | from base import Report,ModelReport,QuerySetReport,SQLReport,DateSQLReport 3 | 4 | # TODO make this seperate from vitalik's registry methods 5 | _registry = {} 6 | 7 | def register(klass): 8 | """ 9 | Registers a Report Engine Report. This gets the namespace and class's slug, puts them in a tuple, and stores the 10 | report, indexed by the namespace, slug tuple. 11 | 12 | :param klass: The class of the report to register. 13 | :return: 14 | """ 15 | _registry[(klass.namespace,klass.slug)] = klass 16 | 17 | def get_report(namespace,slug): 18 | """ 19 | Fetches a report from the registry, by namespace and slug. 20 | 21 | :param namespace: The report namespace, a string. 22 | :param slug: The repot slug, a string 23 | :return: A subclass of reportengine.base.Report 24 | """ 25 | try: 26 | return _registry[(namespace,slug)] 27 | except KeyError: 28 | raise Exception("No such report '%s'" % slug) 29 | 30 | def all_reports(): 31 | """ 32 | Gets all reports from the registry 33 | 34 | :return: A list of reports, subclasses of reportengine.base.Report 35 | """ 36 | return _registry.items() 37 | 38 | def autodiscover(): 39 | """ 40 | Looks for a file called 'reports.py' in your Django Application, then automatically imports that file, causing your 41 | reports to be loaded and registered. 42 | """ 43 | from django.conf import settings 44 | REPORTING_SOURCE_FILE = getattr(settings, 'REPORTING_SOURCE_FILE', 'reports') 45 | for app in settings.INSTALLED_APPS: 46 | try: 47 | app_path = __import__(app, {}, {}, [app.split('.')[-1]]).__path__ 48 | except AttributeError: 49 | continue 50 | 51 | try: 52 | imp.find_module(REPORTING_SOURCE_FILE, app_path) 53 | except ImportError: 54 | continue 55 | __import__('%s.%s' % (app, REPORTING_SOURCE_FILE)) 56 | 57 | 58 | -------------------------------------------------------------------------------- /reportengine/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reports base classes. This reports module tries to provide an ORM agnostic reports engine that will allow nice reports 3 | to be generated and exportable in a variety of formats. It seeks to be easy to use with query sets, raw SQL, or pure 4 | python. An additional goal is to have the reports be managed by model instances as well (e.g. a generic SQL based 5 | report that can be done in the backend). 6 | """ 7 | from django import forms 8 | from django.db.models.fields.related import RelatedField 9 | from filtercontrols import * 10 | from outputformats import * 11 | import datetime 12 | 13 | # Pulled from vitalik's Django-reporting 14 | def get_model_field(model, name): 15 | """ 16 | Gets a field from a Django model. 17 | 18 | :param model: A Django model, this should be the class itself. 19 | :param name: A Django model's field. 20 | :return: The field from the model, a subclass of django.db.models.Model 21 | """ 22 | return model._meta.get_field(name) 23 | 24 | # Based on vitalik's Django-reporting 25 | def get_lookup_field(model, original, lookup): 26 | """ 27 | Gets a lookup field from a django model, this recursively follows relations 28 | that are indicated by Django's __ notation. 29 | 30 | If there were a model like Customer -> Address -> Street (where even street is a model), 31 | calling get_lookup_field(Customer, "address__street__line1") would return 32 | (line1 (a CharField), and Street (a subclass of Model)) 33 | 34 | :param model: A django model, this should be the actual Model class. 35 | :param original: A django model, this should be the initial model class. 36 | It seems this is not used by the function. 37 | :param lookup: The django lookup string, delimited by __ 38 | :return: A tuple of (field, model) where model is a subclass of django.db.models.Model and field is a 39 | subclass of django.db.models.fields.Field 40 | """ 41 | parts = lookup.split('__') 42 | field = get_model_field(model, parts[0]) 43 | if not isinstance(field, RelatedField) or len(parts) == 1: 44 | return field,model 45 | rel_model = field.rel.to 46 | next_lookup = '__'.join(parts[1:]) 47 | return get_lookup_field(rel_model, original, next_lookup) 48 | 49 | class Report(object): 50 | """ 51 | An abstract reportengine report. Concrete report types inherit from this. Override get_rows to make this concrete. 52 | 53 | For Example:: 54 | 55 | class MyReport(Report): 56 | def get_rows(self, *args, **kwargs): 57 | return [(x,x*10) for x in range(0,100)], (('total', 100),) 58 | """ 59 | verbose_name="Abstract Report" 60 | namespace = "Default" 61 | slug ="base" 62 | labels = None 63 | per_page=100 64 | can_show_all=True 65 | output_formats=[AdminOutputFormat(),CSVOutputFormat()] 66 | if XLS_AVAILABLE: 67 | output_formats.append(XLSOutputFormat()) 68 | allow_unspecified_filters = False 69 | date_field = None # if specified will lookup for this date field. .this is currently limited to queryset based lookups 70 | default_mask = {} # a dict of filter default values. Can be callable 71 | 72 | # TODO add charts = [ {'name','type e.g. bar','data':(0,1,3) cols in table}] 73 | # then i can auto embed the charts at the top of the report based upon that data.. 74 | 75 | def get_default_mask(self): 76 | """ 77 | Builds default mask. The filter is merged with this to create the filter for the report. Items can be 78 | callable and will be resolved when called here (which should be at view time). 79 | 80 | :return: a dictionary of filter key/value pairs 81 | """ 82 | m={} 83 | for k in self.default_mask.keys(): 84 | v=self.default_mask[k] 85 | m[k] = callable(v) and v() or v 86 | return m 87 | 88 | def get_filter_form(self, data): 89 | """ 90 | Returns a form with data. 91 | 92 | :param data: Should be a dictionary, with filter data in it. 93 | :return: A form that is ready for validation. 94 | """ 95 | form = forms.Form(data=data) 96 | return form 97 | 98 | # CONSIDER maybe an "update rows"? 99 | # CONSIDER should the resultant rows be a generator instead of a list? 100 | # CONSIDER should paging be dealt with here to more intelligently handle aggregates? 101 | def get_rows(self,filters={},order_by=None): 102 | """ 103 | Given filter parameters and an order by field, this returns the actual rows of the report. 104 | 105 | :param filters: The parameters by which this report should be filtered. 106 | :param order_by: The field by which this report should be ordered. 107 | :return: A tuple (resultant rows, metadata) 108 | """ 109 | raise NotImplementedError("Subclass should return ([],('total',0),)") 110 | 111 | 112 | # CONSIDER do this by day or by month? month seems most efficient in terms of optimizing queries 113 | # CONSIDER - should this be removed from the API? Is it implemented by any subclasses? 114 | def get_monthly_aggregates(self,year,month): 115 | """Called when assembling a calendar view of reports. This will be queried for every day, so must be quick""" 116 | # CONSIDER worry about timezone? or just assume Django has this covered? 117 | raise NotImplementedError("Still an idea in the works") 118 | 119 | class QuerySetReport(Report): 120 | """ 121 | A report that is based on a Django ORM Queryset. 122 | """ 123 | # TODO make labels more addressable. now fixed to fields in model. what happens with relations? 124 | labels = None 125 | queryset = None 126 | """ 127 | list_filter must contain either ModelFields or FilterControls 128 | """ 129 | list_filter = [] 130 | 131 | def get_filter_form(self, data): 132 | """ 133 | get_filter_form constructs a filter form, with the appropriate filtercontrol fields, based on the data passed. 134 | 135 | If the item in list_filter is a FilterControl, then the control will be added to the form filters. 136 | 137 | If the item in list_filter is a field lookup string, then a pre-registered filtercontrol corresponding to that field 138 | may be added to the form filters. 139 | 140 | This will follow __ relations (see get_lookup_field docs above) 141 | 142 | :param data: A dictionary of filter fields. 143 | :return: A form with the filtered fields. 144 | """ 145 | # NOTE - get_lookup_field does follow __ relations, so not sure about the above comment. 146 | # TODO iterate through list filter and create appropriate widget and prefill from request 147 | form = forms.Form(data=data) 148 | for f in self.list_filter: 149 | # Allow specification of custom filter control, or specify field name (and label?) 150 | if isinstance(f,FilterControl): 151 | control=f 152 | else: 153 | mfi,mfm=get_lookup_field(self.queryset.model,self.queryset.model,f) 154 | # TODO allow label as param 2 155 | control = FilterControl.create_from_modelfield(mfi,f) 156 | if control: 157 | fields = control.get_fields() 158 | form.fields.update(fields) 159 | form.full_clean() 160 | return form 161 | 162 | def get_queryset(self, filters, order_by, queryset=None): 163 | """ 164 | Given filters, an order_by and an optional query set, this returns a queryset for this report. Override this 165 | to change the querysets in your reports. 166 | 167 | :param filters: A dictionary of field/value pairs that the report can be filtered on. 168 | :param order_by: The field or statement by which this queryset should be ordered. 169 | :param queryset: An optional queryset. If None, self.queryset will be used. 170 | :return: A filtered and ordered queryset. 171 | """ 172 | if queryset is None: 173 | queryset = self.queryset 174 | queryset = queryset.filter(**filters) 175 | if order_by: 176 | queryset = queryset.order_by(order_by) 177 | return queryset 178 | 179 | def get_rows(self,filters={},order_by=None): 180 | """ 181 | Given the rows and order_by value, this returns the actual report tuple. This needn't be overriden by 182 | subclasses unless special functionality is needed. Instead, consider overriding `get_queryset.` 183 | 184 | :param filters: A dictionary of field/value pairs that the report can be filtered on. 185 | :param order_by: The field or statement by which this queryset should be ordered. 186 | 187 | :return: A tuple of rows and metadata. 188 | """ 189 | qs = self.get_queryset(filters, order_by) 190 | return qs.values_list(*self.labels),(("total",qs.count()),) 191 | 192 | class ModelReport(QuerySetReport): 193 | """ 194 | A report on a specific django model. Subclasses must define `model` on the class. 195 | """ 196 | model = None 197 | 198 | def __init__(self): 199 | """ 200 | Instantiate the ModelReport 201 | """ 202 | super(ModelReport, self).__init__() 203 | self.queryset = self.model.objects.all() 204 | 205 | def get_queryset(self, filters, order_by, queryset=None): 206 | """ 207 | Gets a report based on the Model's fields, given filters, an order by, and an optional queryset. 208 | 209 | :param filters: The dictionary of filters with which to filter this model report. 210 | :param order_by: The field by which this report will be ordered. 211 | :param queryset: An optional queryset. If none, this will use a queryset that gets all instances of 212 | the given model. 213 | 214 | :return: A filtered queryset. 215 | """ 216 | if queryset is None and self.queryset is None: 217 | queryset = self.model.objects.all() 218 | return super(ModelReport, self).get_queryset(filters, order_by, queryset) 219 | 220 | class SQLReport(Report): 221 | """ 222 | A subclass of Report, used with raw SQL. 223 | """ 224 | row_sql=None # sql statement with named parameters in python syntax (e.g. "%(age)s" ) 225 | aggregate_sql=None # sql statement that brings in aggregates. pulls from column name and value for first row only 226 | query_params=[] # list of tuples, (name,label,datatype) where datatype is a mapping to a registerd filtercontrol 227 | 228 | #TODO this should be _private. 229 | def get_connection(self): 230 | """ 231 | Gets the django database connection. 232 | 233 | :return: The database connection. 234 | """ 235 | from django.db import connection 236 | return connection 237 | 238 | #TODO this should be _private. 239 | def get_cursor(self): 240 | """ 241 | Gets the cursor for the connection. 242 | 243 | :return: Database connection cursor 244 | """ 245 | return self.get_connection().cursor() 246 | 247 | #TODO use string formatting instead of older python replacement 248 | def get_row_sql(self, filters, order_by): 249 | """ 250 | This applies filters directly to the SQL string, which should contain python keyed strings. 251 | 252 | :param filters: A dictionary of filters to apply to this sql. 253 | :param order_by: This is ignored, but may be used by subclasses. 254 | :return: The text-replaced SQL, or none if self.row_sql doesn't exist. 255 | """ 256 | if self.row_sql: 257 | return self.row_sql % filters 258 | return None 259 | 260 | def get_aggregate_sql(self, filters): 261 | """ 262 | This applies filters to the aggregate SQL. 263 | 264 | :param filters: A dictoinary of filters to apply to the sql. 265 | :return: The text-replaced SQL or None if self.aggregate_sql doesn't exist. 266 | """ 267 | if self.aggregate_sql: 268 | return self.aggregate_sql % filters 269 | return None 270 | 271 | #TODO make this _private. 272 | #TODO Instead of fetchall, use a generator. 273 | def get_row_data(self, filters, order_by): 274 | """ 275 | Returns the cursor based on a filter dictionary. 276 | 277 | :param filters: A dictionary of field->value filters to filter the report. 278 | :param order_by: The field by which this report should be ordered. (Currently ignored by get_row_sql) 279 | :return: A list of all results (from fetchall) 280 | """ 281 | sql = self.get_row_sql(filters, order_by) 282 | if not sql: 283 | return [] 284 | cursor = self.get_cursor() 285 | cursor.execute(sql) 286 | return cursor.fetchall() 287 | 288 | def get_aggregate_data(self, filters): 289 | """ 290 | Returns the cursor based on a filter dictionary. 291 | 292 | :param filters: A dictionary of paramters by which this report will be filtered. 293 | :return: The aggregates for this report, based on the aggregate sql. 294 | """ 295 | sql = self.get_aggregate_sql(filters) 296 | if not sql: 297 | return [] 298 | cursor = self.get_cursor() 299 | cursor.execute(sql) 300 | result = cursor.fetchone() 301 | 302 | agg = list() 303 | for i in range(len(result)): 304 | agg.append((cursor.description[i][0],result[i])) 305 | return agg 306 | 307 | def get_filter_form(self, data): 308 | """ 309 | Returns the filter form based on filter data. 310 | 311 | :param data: A dictionary with filters that should be used. 312 | :return: A filtering form for this report. 313 | """ 314 | form=forms.Form(data=data) 315 | for q in self.query_params: 316 | control = FilterControl.create_from_datatype(q[2],q[0],q[1]) 317 | fields = control.get_fields() 318 | form.fields.update(fields) 319 | form.full_clean() 320 | return form 321 | 322 | # CONSIDER not ideal in terms paging, would be better to fetch within a range.. 323 | # TODO Make this work with order_by 324 | # TODO Use a generator instead of getting a big list of results. 325 | # TODO Make the return from this function match the implied contract from all of the other subclasses of Report. 326 | def get_rows(self,filters={},order_by=None): 327 | """ 328 | This returns all of the rows in the report, ignores order_by 329 | 330 | :param filters: A dictionary of filters upon which to filter the report. 331 | :param order_by: The field by which the report should be ordered. 332 | :return: A tuple of rows and aggregate data (no meta data!) 333 | """ 334 | rows = self.get_row_data(filters, order_by) 335 | agg = self.get_aggregate_data(filters) 336 | return rows,agg 337 | 338 | class DateSQLReport(SQLReport): 339 | """ 340 | A date based SQL report. Implies that the row and aggregate SQL should contain date__gte and date__lt variables. 341 | """ 342 | aggregate_sql=None 343 | query_params=[("date","Date","datetime")] 344 | date_field="date" 345 | default_mask={ 346 | "date__gte":lambda: (datetime.datetime.today() -datetime.timedelta(days=30)).strftime("%Y-%m-%d"), 347 | "date__lt":lambda: (datetime.datetime.today() + datetime.timedelta(days=1)).strftime("%Y-%m-%d"), 348 | } 349 | 350 | # TODO build AnnotatedReport that deals with .annotate functions in ORM 351 | 352 | -------------------------------------------------------------------------------- /reportengine/filtercontrols.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based loosely on admin filterspecs, these are more focused on delivering controls appropriate per field type 3 | 4 | Different filter controls can be registered per field type. When assembling a set of filter controls, these field 5 | types will generate the appropriate set of fields. These controls will be based upon what is appropriate for that field. 6 | For instance, a datetimefield for filtering requires a start/end. A boolean field needs an "all", "true" or "false" in 7 | radio buttons. 8 | 9 | It is sometimes necessary to manually add FilterControls to a report/field because there is no default registration, or 10 | because multiple controls may be appropriate for a particular field. 11 | 12 | """ 13 | from django import forms 14 | from django.db import models 15 | from django.utils.translation import ugettext as _ 16 | from django.utils.datastructures import SortedDict 17 | 18 | # TODO build register and lookup functions 19 | # TODO figure out how to manage filters and actual request params, which aren't always 1-to-1 (e.g. datetime) 20 | 21 | class FilterControl(object): 22 | """ 23 | FilterControl is a quasi-abstract factory parent. It's subclasses determine how the fields should be represented 24 | and how they should behave. 25 | 26 | By default, this will return an exact match charfield. 27 | 28 | """ 29 | filter_controls=[] 30 | def __init__(self,field_name,label=None): 31 | """ 32 | Constructor for the FilterControl 33 | 34 | :param field_name: The name of the field that this filtercontrol will filter on. 35 | :param label: The label for this filtercontrol, if none is set, the field_name will be when rendering. 36 | """ 37 | self.field_name=field_name 38 | self.label=label 39 | 40 | def get_fields(self): 41 | """ 42 | Gets the FormField objects for this filter control. This is an exact match field. 43 | 44 | :return: a dictionary of field name -> django formfield pairs. 45 | """ 46 | return {self.field_name:forms.CharField(label=self.label or self.field_name,required=False)} 47 | 48 | # Pulled from django.contrib.admin.filterspecs 49 | def register(cls, test, factory, datatype): 50 | """ 51 | Registers a filter control against a specific datatype. The filter controller is added to 52 | filter_controls. 53 | 54 | Depending on whether create_from_modelfield or create_from_datatype (below) is used, the test or datatype will 55 | be used to create FilterControls in the future. 56 | 57 | :param test: A function to test whether the filtercontrol should be displayed (?) 58 | :param factory: The filter control class. When called, you will get a subclass of FilterControl. 59 | :param datatype: The field type that is filtered by this control (?) 60 | """ 61 | cls.filter_controls.append((test, factory, datatype)) 62 | register = classmethod(register) 63 | 64 | def create_from_modelfield(cls, f, field_name, label=None): 65 | """ 66 | create_from_modelfield returns a FilterControl subclass, based on the field type. 67 | "f" must be accepted by the test methods defined during Filter Control registration, 68 | so it's type could actually vary depending on various implementations, but to match the registered 69 | test functions in this module, it should be a subclass of django.db.models.field.Field 70 | 71 | :param f: The modelfield to test. If the test passes, the related factory will be returned. 72 | :param field_name: The name of the field. 73 | :param label: The label for the filter control. 74 | :return: A FilterControl subclass. 75 | """ 76 | for test, factory, datatype in cls.filter_controls: 77 | if test(f): 78 | return factory(field_name,label) 79 | create_from_modelfield = classmethod(create_from_modelfield) 80 | 81 | def create_from_datatype(cls, datatype, field_name, label=None): 82 | """ 83 | create_from_datatype returns a FilterControl subclass, based on the datatype. If datatype matches a registered 84 | datatype, then the appropriate subclass is instantiated and returned. 85 | 86 | :param datatype: The datatype of the field. A string. 87 | :param field_name: The name of the FilterControl subclass - passed thru to the constructor 88 | :param label: The label for the FilterControl subclass - passed through to the constructor 89 | :return: A FilterControl subclass. 90 | """ 91 | for test, factory, dt in cls.filter_controls: 92 | if dt == datatype: 93 | return factory(field_name,label) 94 | create_from_datatype = classmethod(create_from_datatype) 95 | 96 | FilterControl.register(lambda m: isinstance(m,models.CharField),FilterControl,"char") 97 | 98 | class DateTimeFilterControl(FilterControl): 99 | def get_fields(self): 100 | """ 101 | Returns DateTimeInput Form Fields for filtering reports. 102 | 103 | :return: A dictionary containing hte start and end dates for the filtercontrol 104 | """ 105 | ln=self.label or self.field_name 106 | start=forms.CharField(label=_("%s From")%ln,required=False,widget=forms.DateTimeInput(attrs={'class': 'vDateField'})) 107 | end=forms.CharField(label=_("%s To")%ln,required=False,widget=forms.DateTimeInput(attrs={'class': 'vDateField'})) 108 | return SortedDict([("%s__gte"%self.field_name, start), 109 | ("%s__lt"%self.field_name, end),]) 110 | 111 | FilterControl.register(lambda m: isinstance(m,models.DateTimeField),DateTimeFilterControl,"datetime") 112 | 113 | class BooleanFilterControl(FilterControl): 114 | def get_fields(self): 115 | """ 116 | Returns a Boolean Filter Control for filtering reports. 117 | 118 | :return: A dictionary of the field name and filter controls for the field. 119 | """ 120 | return {self.field_name:forms.CharField(label=self.label or self.field_name, 121 | required=False,widget=forms.RadioSelect(choices=(('','All'),('1','True'),('0','False'))),initial='A')} 122 | 123 | FilterControl.register(lambda m: isinstance(m,models.BooleanField),BooleanFilterControl,"boolean") 124 | 125 | # TODO How do I register this one? 126 | class StartsWithFilterControl(FilterControl): 127 | def get_fields(self): 128 | """ 129 | Returns a charfield to generate a report where a value "starts with" the given string. 130 | 131 | :return: A dictionary of the field name with a __startswith and filter controls for the field. 132 | """ 133 | return {"%s__startswith"%self.field_name:forms.CharField(label=_("%s Starts With")%(self.label or self.field_name), 134 | required=False)} 135 | 136 | # CONSIDER How to register the choicefiltercontrol as well. 137 | class ChoiceFilterControl(FilterControl): 138 | def __init__(self, *args, **kwargs): 139 | """ 140 | Constructor for the Choice Filter Control 141 | :param kwargs: Can contain "choices" and "initial" value, used to render the form. Must contain "field_name" 142 | and "label," as these are passed to the FilterControl constructor. 143 | """ 144 | self.choices = kwargs.pop('choices', []) 145 | self.initial = kwargs.pop('initial', None) 146 | super(ChoiceFilterControl, self).__init__(*args, **kwargs) 147 | 148 | def get_fields(self): 149 | """ 150 | Returns a dictionary with the field name and choice field. 151 | 152 | :return: The dictionary's key is the field's name, the value is a choice field 153 | with the choices and initial value as passed to the constructor. 154 | """ 155 | return {self.field_name: forms.ChoiceField( 156 | choices=self.choices, 157 | label=self.label or self.field_name, 158 | required=False, 159 | initial=self.initial, 160 | )} 161 | -------------------------------------------------------------------------------- /reportengine/jsonfield.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django JSON Field. This extends Django Model Fields to store JSON as a field-type. 3 | """ 4 | #TODO - Move this to utils or another application. This is tangential to reporting and useful for other things. 5 | 6 | from django.db import models 7 | from django.utils import simplejson 8 | from django.core.serializers.json import DjangoJSONEncoder 9 | 10 | import logging 11 | 12 | class JSONFieldDescriptor(object): 13 | def __init__(self, field, datatype=dict): 14 | """ 15 | Create a JSONFieldDescriptor 16 | 17 | :param field: The field to create the descriptor for. 18 | :param datatype: The datatype of the descriptor. 19 | """ 20 | self.field = field 21 | self.datatype = datatype 22 | 23 | def __get__(self, instance=None, owner=None): 24 | if instance is None: 25 | raise AttributeError( 26 | "The '%s' attribute can only be accessed from %s instances." 27 | % (self.field.name, owner.__name__)) 28 | 29 | if not hasattr(instance, self.field.get_cache_name()): 30 | data = instance.__dict__.get(self.field.attname, self.datatype()) 31 | if not isinstance(data, self.datatype): 32 | data = self.field.loads(data) 33 | if data is None: 34 | data = self.datatype() 35 | setattr(instance, self.field.get_cache_name(), data) 36 | 37 | return getattr(instance, self.field.get_cache_name()) 38 | 39 | def __set__(self, instance, value): 40 | if not isinstance(value, (self.datatype, basestring)): 41 | value = self.datatype(value) 42 | instance.__dict__[self.field.attname] = value 43 | try: 44 | delattr(instance, self.field.get_cache_name()) 45 | except AttributeError: 46 | pass 47 | 48 | 49 | class JSONField(models.TextField): 50 | """ 51 | A field for storing JSON-encoded data. The data is accessible as standard 52 | Python data types and is transparently encoded/decoded to/from a JSON 53 | string in the database. 54 | """ 55 | serialize_to_string = True 56 | descriptor_class = JSONFieldDescriptor 57 | 58 | def __init__(self, verbose_name=None, name=None, 59 | encoder=DjangoJSONEncoder(), decoder=simplejson.JSONDecoder(), 60 | datatype=dict, 61 | **kwargs): 62 | """ 63 | Create a new JSONField 64 | 65 | :param verbose_name: The verbose name of the field 66 | :param name: The short name of the field. 67 | :param encoder: The encoder used to turn native datatypes into JSON. 68 | :param decoder: The decoder used to turn JSON into native datatypes. 69 | :param datatype: The native datatype to store. 70 | :param kwargs: Other arguments to pass to parent constructor. 71 | """ 72 | blank = kwargs.pop('blank', True) 73 | models.TextField.__init__(self, verbose_name, name, blank=blank, 74 | **kwargs) 75 | self.encoder = encoder 76 | self.decoder = decoder 77 | self.datatype = datatype 78 | 79 | #TODO - Is this used anywhere? If not, let's remove it. 80 | def db_type(self, connection=None): 81 | """ 82 | Returns the database type. Overrides django.db.models.Field's db_type. 83 | 84 | :param connection: The database connection - defaults to none. 85 | :return: The database type. Always returns the string 'text'. 86 | """ 87 | return "text" 88 | 89 | 90 | def contribute_to_class(self, cls, name): 91 | """ 92 | Overrides django.db.models.Field's contribute to class to handle descriptors. 93 | 94 | :param cls: The class to contribute to. 95 | :param name: The name. 96 | """ 97 | super(JSONField, self).contribute_to_class(cls, name) 98 | setattr(cls, self.name, self.descriptor_class(self, self.datatype)) 99 | 100 | def pre_save(self, model_instance, add): 101 | "Returns field's value just before saving. If a descriptor, get's that instead of value from object." 102 | descriptor = getattr(model_instance, self.attname) 103 | if isinstance(descriptor, self.datatype): 104 | return descriptor 105 | return self.field.value_from_object(model_instance) 106 | 107 | def get_db_prep_save(self, value, *args, **kwargs): 108 | if not isinstance(value, basestring): 109 | value = self.dumps(value) 110 | 111 | return super(JSONField, self).get_db_prep_save(value, *args, **kwargs) 112 | 113 | def value_to_string(self, obj): 114 | """ 115 | Turns the value to a JSON string. 116 | :param obj: An object. 117 | :return: A string. 118 | """ 119 | return self.dumps(self.value_from_object(obj)) 120 | 121 | def dumps(self, data): 122 | """ 123 | Encodes data and dumps. 124 | :param data: A value. 125 | :return: An encoded string. 126 | """ 127 | return self.encoder.encode(data) 128 | 129 | def loads(self, val): 130 | """ 131 | 132 | :param val: A JSON encoddd string. 133 | :return: A dict with data from val 134 | """ 135 | try: 136 | val = self.decoder.decode(val)#, encoding=settings.DEFAULT_CHARSET) 137 | 138 | # XXX We need to investigate why this is happening once we have 139 | # a solid repro case. 140 | if isinstance(val, basestring): 141 | logging.warning("JSONField decode error. Expected dictionary, " 142 | "got string for input '%s'" % val) 143 | # For whatever reason, we may have gotten back 144 | val = self.decoder.decode(val)#, encoding=settings.DEFAULT_CHARSET) 145 | except ValueError: 146 | val = None 147 | return val 148 | 149 | def south_field_triple(self): 150 | """ 151 | Returns a suitable description of this field for South." 152 | 153 | :return: A tuple of field_class, args and kwargs from South's introspector. 154 | """ 155 | # We'll just introspect the _actual_ field. 156 | from south.modelsinspector import introspector 157 | field_class = "django.db.models.fields.TextField" 158 | args, kwargs = introspector(self) 159 | # That's our definition! 160 | return (field_class, args, kwargs) 161 | 162 | -------------------------------------------------------------------------------- /reportengine/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/reportengine/management/__init__.py -------------------------------------------------------------------------------- /reportengine/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/reportengine/management/commands/__init__.py -------------------------------------------------------------------------------- /reportengine/management/commands/cleanup_stale_reports.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | class Command(BaseCommand): 4 | help = 'Remove Stale Reports' 5 | 6 | def handle(self, *args, **kwargs): 7 | from reportengine.models import ReportRequest 8 | ReportRequest.objects.cleanup_stale_requests() 9 | 10 | -------------------------------------------------------------------------------- /reportengine/management/commands/generate_report.py: -------------------------------------------------------------------------------- 1 | import reportengine 2 | import sys 3 | from django.core.management.base import BaseCommand, CommandError 4 | from optparse import make_option 5 | from reportengine.outputformats import CSVOutputFormat, XMLOutputFormat 6 | from urlparse import parse_qsl 7 | 8 | ## ASSUMPTIONS: We're running this from the command line, so we can ignore 9 | ## - AdminOutputFormat 10 | ## - pagination 11 | 12 | ## TODO: Be more DRY about how the report is generated, including 13 | ## outputformat selection and filters and context creation 14 | 15 | class Command(BaseCommand): 16 | help = 'Run a report' 17 | option_list = BaseCommand.option_list + ( 18 | make_option('-n', '--namespace', 19 | dest='namespace', 20 | default=None, 21 | help='Report namespace' 22 | ), 23 | make_option('-r', '--report', 24 | dest='report', 25 | default=None, 26 | help='Name of report' 27 | ), 28 | make_option('-f', '--file', 29 | dest='file', 30 | default=None, 31 | help='Path to file (defaults to sys.stdout)' 32 | ), 33 | make_option('-o', '--format', 34 | dest='format', 35 | default='csv', 36 | help='Output format slug (csv, xml, etc)' 37 | ), 38 | make_option('-q', '--filter', 39 | dest='filter', 40 | default='', 41 | help='Filter args as a querystring (foo=bar&fizz=buzz)' 42 | ), 43 | make_option('-b', '--order-by', 44 | dest='order_by', 45 | default=None, 46 | help='Field to order the report by' 47 | ), 48 | ) 49 | 50 | def handle(self, *args, **kwargs): 51 | if not kwargs['namespace'] or not kwargs['report']: 52 | raise CommandError('--namespace and --report are required') 53 | 54 | ## Try to open the file path if specified, default to sys.stdout if it wasn't 55 | if kwargs['file']: 56 | try: 57 | output = file(kwargs['file'], 'w') 58 | except Exception: 59 | raise CommandError('Could not open file path for writing') 60 | else: 61 | output = sys.stdout 62 | 63 | reportengine.autodiscover() ## Populate the reportengine registry 64 | try: 65 | report = reportengine.get_report(kwargs['namespace'], kwargs['report'])() 66 | except Exception, err: 67 | raise CommandError('Could not find report for (%(namespace)s, %(report)s)' % kwargs) 68 | 69 | 70 | ## Parse our filters 71 | request = dict(parse_qsl(kwargs['filter'])) 72 | filter_form = report.get_filter_form(request) 73 | if filter_form.fields: 74 | if filter_form.is_valid(): 75 | filters = filter_form.cleaned_data 76 | else: 77 | filters = {} 78 | else: 79 | if report.allow_unspecified_filters: 80 | filters = request 81 | else: 82 | filters = {} 83 | 84 | # Remove blank filters 85 | for k in filters.keys(): 86 | if filters[k] == '': 87 | del filters[k] 88 | 89 | ## Update the mask and run the report! 90 | mask = report.get_default_mask() 91 | mask.update(filters) 92 | rows, aggregates = report.get_rows(mask, order_by=kwargs['order_by']) 93 | 94 | ## Get our output format, setting a default if one wasn't set or isn't valid for this report 95 | outputformat = None 96 | if output: 97 | for format in report.output_formats: 98 | if format.slug == kwargs['format']: 99 | outputformat = format 100 | if not outputformat: 101 | ## By default, [0] is AdminOutputFormat, so grab the last one instead 102 | outputformat = report.output_formats[-1] 103 | 104 | context = { 105 | 'report': report, 106 | 'title': report.verbose_name, 107 | 'rows': rows, 108 | 'filter_form': filter_form, 109 | 'aggregates': aggregates, 110 | 'paginator': None, 111 | 'cl': None, 112 | 'page': 0, 113 | 'urlparams': kwargs['filter'] 114 | } 115 | 116 | outputformat.generate_output(context, output) 117 | output.close() 118 | 119 | -------------------------------------------------------------------------------- /reportengine/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import Q 3 | 4 | import datetime 5 | import reportengine 6 | 7 | from jsonfield import JSONField 8 | from settings import STALE_REPORT_SECONDS 9 | 10 | class AbstractScheduledTask(models.Model): 11 | """ 12 | Base class of scheduled task. 13 | """ 14 | request_made = models.DateTimeField(default=datetime.datetime.now, db_index=True) 15 | completion_timestamp = models.DateTimeField(blank=True, null=True) 16 | token = models.CharField(max_length=255, db_index=True) 17 | task = models.CharField(max_length=128, blank=True) 18 | 19 | def get_task_function(self): 20 | raise NotImplementedError 21 | 22 | def task_status(self): 23 | if self.task: 24 | func = self.get_task_function() 25 | result = func.AsyncResult(self.task) 26 | return result.state 27 | return None 28 | 29 | def schedule_task(self): 30 | func = self.get_task_function() 31 | return func.delay(self.token) 32 | 33 | class Meta: 34 | abstract = True 35 | 36 | class ReportRequestManager(models.Manager): 37 | """ 38 | The manager for report requests. 39 | """ 40 | def completed(self): 41 | """ 42 | Gets all completed requests. 43 | :return: A queryset of completed requests. 44 | """ 45 | return self.filter(completion_timestamp__isnull=False) 46 | 47 | def stale(self): 48 | """ 49 | Gets all stale requests, based on "STALE_REPORT_SECONDS" settings. 50 | :return: A queryset of all outstanding requests older than STALE_REPORT_SECONDS. 51 | """ 52 | cutoff = datetime.datetime.now() - datetime.timedelta(seconds=STALE_REPORT_SECONDS) 53 | return self.filter(completion_timestamp__lte=cutoff).filter(Q(viewed_on__lte=cutoff) | Q(viewed_on__isnull=True)) 54 | 55 | def cleanup_stale_requests(self): 56 | """ 57 | Deletes all stale requests. 58 | :return: A queryset of deleted requests. 59 | """ 60 | return self.stale().delete() 61 | 62 | class ReportRequest(AbstractScheduledTask): 63 | """ 64 | Session based report request. Report request is made, and the token for the request is stored in the session so 65 | only that user can access this report. Task system generates the report and drops it into "content". 66 | When content is no longer null, user sees full report and their session token is cleared. 67 | """ 68 | # TODO consider cleanup (when should this be happening? after the request is made? What about caching? throttling?) 69 | namespace = models.CharField(max_length=255) 70 | slug = models.CharField(max_length=255) 71 | params = JSONField() #GET params 72 | viewed_on = models.DateTimeField(blank=True, null=True) 73 | aggregates = JSONField(datatype=list) 74 | 75 | objects = ReportRequestManager() 76 | 77 | def get_report(self): 78 | """ 79 | Gets a report object, based on this ReportRequest's slug and namespace. 80 | :return: A subclass of reportengine.Report 81 | """ 82 | return reportengine.get_report(self.namespace, self.slug)() 83 | 84 | @models.permalink 85 | def get_absolute_url(self): 86 | """ 87 | This is a URL to a message that either the report is currently being processed, or the report results. 88 | 89 | :return: A tuple of reports-request-view, the report token and no keywords. 90 | """ 91 | return ('reports-request-view', [self.token], {}) 92 | 93 | @models.permalink 94 | def get_report_url(self): 95 | """ 96 | This is a URL to the report constructor view. 97 | 98 | :return: A tuple of report-view, and the namespace and slug for the referenced report for this request. 99 | """ 100 | return ('reports-view', [self.namespace, self.slug], {}) 101 | 102 | def build_report(self): 103 | """ 104 | build_report does this: 105 | fetch the report associated to this report request, 106 | constructs the filter form, based on this report request's params, 107 | get the report's default mask, 108 | updates it with the filters (again from params), 109 | gets the report's results, ordered by the 'order_by' value in params 110 | this then saves every row in the report as a reportrequestrow object. 111 | the aggregates and completion timestamp are stored on this request. 112 | """ 113 | kwargs = self.params 114 | 115 | # THis is like 90% the same 116 | #reportengine.autodiscover() ## Populate the reportengine registry 117 | try: 118 | report = self.get_report() 119 | except Exception, err: 120 | raise err 121 | 122 | filter_form = report.get_filter_form(kwargs) 123 | if filter_form.fields: 124 | if filter_form.is_valid(): 125 | filters = filter_form.cleaned_data 126 | else: 127 | filters = {} 128 | else: 129 | if report.allow_unspecified_filters: 130 | filters = kwargs 131 | else: 132 | filters = {} 133 | 134 | # Remove blank filters 135 | for k in filters.keys(): 136 | if filters[k] == '': 137 | del filters[k] 138 | 139 | ## Update the mask and run the report! 140 | mask = report.get_default_mask() 141 | mask.update(filters) 142 | rows, aggregates = report.get_rows(mask, order_by=kwargs.get('order_by',None)) 143 | 144 | ReportRequestRow.objects.filter(report_request=self).delete() 145 | 146 | for index, row in enumerate(rows): 147 | report_row = ReportRequestRow(report_request=self, row_number=index) 148 | report_row.data = row 149 | report_row.save() 150 | 151 | self.aggregates = aggregates 152 | self.completion_timestamp = datetime.datetime.now() 153 | self.save() 154 | 155 | def get_task_function(self): 156 | from tasks import async_report 157 | return async_report 158 | 159 | class ReportRequestRow(models.Model): 160 | """ 161 | Report Request Row holds report result rows for each report request. 162 | 163 | The data is stored in a list-typed JSONField. 164 | """ 165 | report_request = models.ForeignKey(ReportRequest, related_name='rows') 166 | row_number = models.PositiveIntegerField() 167 | data = JSONField(datatype=list) 168 | 169 | class ReportRequestExport(AbstractScheduledTask): 170 | report_request = models.ForeignKey(ReportRequest, related_name='exports') 171 | format = models.CharField(max_length=10) 172 | #mimetype = models.CharField(max_length=50) 173 | #content_disposition = models.CharField(max_length=200) 174 | payload = models.FileField(upload_to='reportengine/exports/%Y/%m/%d') 175 | 176 | def build_report(self): 177 | """ 178 | Builds the export from a previously-run report. 179 | """ 180 | from views import ReportRowQuery 181 | from urllib import urlencode 182 | 183 | from django.test.client import RequestFactory 184 | from django.core.files.base import ContentFile 185 | 186 | report = self.report_request.get_report() 187 | object_list = ReportRowQuery(self.report_request.rows.all()) 188 | 189 | 190 | kwargs = {'report': report, 191 | 'title':report.verbose_name, 192 | 'rows':object_list, 193 | 'filter_form':report.get_filter_form(data=None), 194 | "aggregates":self.report_request.aggregates, 195 | "cl":None, 196 | 'report_request':self.report_request, 197 | "urlparams":urlencode(self.report_request.params)} 198 | 199 | 200 | outputformat = None 201 | for of in report.output_formats: 202 | if of.slug == self.format: 203 | outputformat = of 204 | 205 | response = outputformat.get_response(kwargs, RequestFactory().get('/')) 206 | #TODO this is a hack 207 | filename = response.get('Content-Disposition', '').rsplit('filename=',1)[-1] 208 | if not filename: 209 | filename = u'%s.%s' % (self.token, self.format) 210 | self.payload.save(filename, ContentFile(response.content), False) 211 | 212 | self.completion_timestamp = datetime.datetime.now() 213 | self.save() 214 | 215 | def get_task_function(self): 216 | from tasks import async_report_export 217 | return async_report_export 218 | 219 | -------------------------------------------------------------------------------- /reportengine/outputformats.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.shortcuts import render_to_response 3 | from django.template.context import RequestContext 4 | from django.http import HttpResponse 5 | from django.utils.encoding import smart_unicode 6 | import csv 7 | from cStringIO import StringIO 8 | from xml.etree import ElementTree as ET 9 | 10 | ## Exporting to XLS requires the xlwt library 11 | ## http://www.python-excel.org/ 12 | try: 13 | import xlwt 14 | XLS_AVAILABLE = True 15 | except ImportError: 16 | XLS_AVAILABLE = False 17 | 18 | class OutputFormat(object): 19 | verbose_name="Abstract Output Format" 20 | slug="output" 21 | no_paging=False 22 | 23 | def generate_output(self, context, output): 24 | ## output is expected to be a file-like object, be it Django Response, 25 | ## StringIO, file, or sys.stdout. Anything sith a .write method should do. 26 | raise NotImplemented("Use a subclass of OutputFormat.") 27 | 28 | def get_response(self,context,request): 29 | raise NotImplemented("Use a subclass of OutputFormat.") 30 | 31 | class AdminOutputFormat(OutputFormat): 32 | verbose_name="Admin Report" 33 | slug="admin" 34 | 35 | def generate_output(self, context, output): 36 | raise NotImplemented("Not necessary for this output format") 37 | 38 | def get_response(self,context,request): 39 | context.update({"output_format":self}) 40 | return render_to_response('reportengine/report.html', context, 41 | context_instance=RequestContext(request)) 42 | 43 | class CSVOutputFormat(OutputFormat): 44 | verbose_name="CSV (comma separated value)" 45 | slug="csv" 46 | no_paging=True 47 | 48 | # CONSIDER perhaps I could use **kwargs, but it is nice to see quickly what is available.. 49 | def __init__(self,quotechar='"',quoting=csv.QUOTE_MINIMAL,delimiter=',',lineterminator='\n'): 50 | self.quotechar=quotechar 51 | self.quoting=quoting 52 | self.delimiter=delimiter 53 | self.lineterminator=lineterminator 54 | 55 | def generate_output(self, context, output): 56 | """ 57 | :param context: should be a dictionary with keys 'aggregates' and 'rows' and 'report' 58 | :param output: should be a file-like object to which output can be written? 59 | :return: modified output object 60 | """ 61 | w=csv.writer(output, 62 | delimiter=self.delimiter, 63 | quotechar=self.quotechar, 64 | quoting=self.quoting, 65 | lineterminator=self.lineterminator) 66 | for a in context["aggregates"]: 67 | w.writerow([smart_unicode(x).encode('utf8') for x in a]) 68 | w.writerow( context["report"].labels) 69 | for r in context["rows"]: 70 | w.writerow([smart_unicode(x).encode('utf8') for x in r]) 71 | return output 72 | 73 | def get_response(self,context,request): 74 | resp = HttpResponse(mimetype='text/csv') 75 | # CONSIDER maybe a "get_filename" from the report? 76 | resp['Content-Disposition'] = 'attachment; filename=%s.csv'%context['report'].slug 77 | self.generate_output(context, resp) 78 | return resp 79 | 80 | 81 | class XLSOutputFormat(OutputFormat): 82 | no_paging = True 83 | slug = 'xls' 84 | verbose_name = 'XLS (Microsoft Excel)' 85 | 86 | def generate_output(self, context, output): 87 | if not XLS_AVAILABLE: 88 | raise ImproperlyConfigured('Missing module xlwt.') 89 | ## Put all our data into a big list 90 | rows = [] 91 | rows.extend(context['aggregates']) 92 | rows.append(context['report'].labels) 93 | rows.extend(context['rows']) 94 | 95 | ## Create the spreadsheet from our data 96 | workbook = xlwt.Workbook(encoding='utf8') 97 | worksheet = workbook.add_sheet('report') 98 | for row_index, row in enumerate(rows): 99 | for col_index, val in enumerate(row): 100 | if isinstance(val, basestring): 101 | val = smart_unicode(val).encode('utf8') 102 | worksheet.write(row_index, col_index, val) 103 | workbook.save(output) 104 | 105 | def get_response(self, context, request): 106 | resp = HttpResponse(mimetype='application/vnd.ms-excel') 107 | resp['Content-Disposition'] = 'attachment; filename=%s.xls' % context['report'].slug 108 | self.generate_output(context, resp) 109 | return resp 110 | 111 | 112 | 113 | class XMLOutputFormat(OutputFormat): 114 | verbose_name="XML" 115 | slug="xml" 116 | no_paging=True 117 | 118 | def __init__(self,root_tag="output",row_tag="entry",aggregate_tag="aggregate"): 119 | self.root_tag=root_tag 120 | self.row_tag=row_tag 121 | self.aggregate_tag=aggregate_tag 122 | 123 | def generate_output(self, context, output): 124 | root = ET.Element(self.root_tag) # CONSIDER maybe a nicer name or verbose name or something 125 | for a in context["aggregates"]: 126 | ae=ET.SubElement(root,self.aggregate_tag) 127 | ae.set("name",a[0]) 128 | ae.text=smart_unicode(a[1]) 129 | rows=context["rows"] 130 | labels=context["report"].labels 131 | for r in rows: 132 | e=ET.SubElement(root,self.row_tag) 133 | for l in range(len(labels)): 134 | e1=ET.SubElement(e,labels[l]) 135 | e1.text = smart_unicode(r[l]) 136 | tree=ET.ElementTree(root) 137 | tree.write(output) 138 | 139 | def get_response(self,context,request): 140 | resp = HttpResponse(mimetype='text/xml') 141 | # CONSIDER maybe a "get_filename" from the report? 142 | resp['Content-Disposition'] = 'attachment; filename=%s.xml'%context['report'].slug 143 | self.generate_output(context, resp) 144 | return resp 145 | -------------------------------------------------------------------------------- /reportengine/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | ASYNC_REPORTS = getattr(settings, "ASYNC_REPORTS", False) 4 | STALE_REPORT_SECONDS = getattr(settings, "STALE_REPORT_SECONDS", 6*60*60) 5 | MAX_ROWS_FOR_QUICK_EXPORT = getattr(settings, "MAX_ROWS_FOR_QUICK_EXPORT", 1000) 6 | -------------------------------------------------------------------------------- /reportengine/tasks.py: -------------------------------------------------------------------------------- 1 | from celery.decorators import task 2 | from models import ReportRequest, ReportRequestExport 3 | import reportengine 4 | 5 | #TODO - Add fixtures for these tasks, so the report cleanup is loaded into celerybeat. 6 | @task() 7 | def async_report(token): 8 | 9 | try: 10 | report_request = ReportRequest.objects.get(token=token) 11 | except ReportRequest.DoesNotExist: 12 | # Error? 13 | return 14 | # THis is like 90% the same 15 | reportengine.autodiscover() ## Populate the reportengine registry 16 | report_request.build_report() 17 | 18 | @task() 19 | def async_report_export(token): 20 | 21 | try: 22 | report_request_export = ReportRequestExport.objects.get(token=token) 23 | except ReportRequestExport.DoesNotExist: 24 | # Error? 25 | return 26 | # THis is like 90% the same 27 | reportengine.autodiscover() ## Populate the reportengine registry 28 | report_request_export.build_report() 29 | 30 | 31 | @task() 32 | def cleanup_stale_reports(): 33 | ReportRequest.objects.cleanup_stale_requests() 34 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/async_wait.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {# TODO note that meta refreshes are deprecated.. but is there a nice clean way to do this otherwise? #} 5 | {% block extrahead %} 6 | 7 | {% endblock %} 8 | 9 | {% block content %} 10 | {% block description %} 11 | {% if report.description %} 12 |

{% trans "Description" %}

13 |
{{ report.description }}
14 | {% endif %} 15 | {% endblock %} 16 |

{% trans "Please wait while your report is generated..." %}

17 | {% endblock %} 18 | 19 | {% block breadcrumbs %} 20 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/calendar.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load adminmedia admin_list i18n %} 3 | 4 | {% block extrastyle %} 5 | 6 | {% endblock %} 7 | 8 | {% block bodyclass %}change-list{% endblock %} 9 | 10 | {% block breadcrumbs %} 11 | 16 | {% endblock %} 17 | 18 | 19 | 20 | {% block content %} 21 |
22 | {% block object-tools %}{% endblock %} 23 | 24 | {% block calendarcontent %}{% endblock %} 25 | 26 |
27 | {% endblock %} 28 | 29 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/calendar_day.html: -------------------------------------------------------------------------------- 1 | {% extends "reportengine/calendar.html" %} 2 | 3 | {% block calendar_breadcrumb %}{{ date|date:"d M Y" }}{% endblock %} 4 | 5 | {% block calendarcontent %} 6 |

Reports for {{ date|date:"d M Y" }}

7 | 8 | 13 | 14 | {% endblock %} 15 | 16 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/calendar_month.html: -------------------------------------------------------------------------------- 1 | {% extends "reportengine/calendar.html" %} 2 | 3 | {% block calendar_breadcrumb %}{{ date|date:"M Y" }}{% endblock %} 4 | 5 | {% block calendarcontent %} 6 |

Reports for {{ date|date:"M Y" }}

7 | 8 | current month 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% for week in calendar %} 26 | 27 | {% for day in week %} 28 | 35 | {% endfor %} 36 | 37 | {% endfor %} 38 |
«»
MTWRFSN
29 | {% if day %} 30 |
31 | {{ day }} 32 |
33 | {% endif %} 34 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/list.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load adminmedia admin_list i18n %} 3 | 4 | {% block extrastyle %} 5 | 6 | {% endblock %} 7 | 8 | 9 | {% block bodyclass %}change-list{% endblock %} 10 | 11 | 12 | {% block breadcrumbs %} 13 | 18 | {% endblock %} 19 | 20 | 21 | 22 | {% block content %} 23 |
24 | {% block object-tools %}{% endblock %} 25 | 26 |
27 | 28 |

Reports

29 | 30 |
31 | Calendar View 32 |
33 | 34 |
35 | {% regroup reports|dictsort:"namespace" by namespace as report_list %} 36 | {% for namespace in report_list %} 37 |

{{ namespace.grouper|title }}

38 | {% for report in namespace.list|dictsort:"verbose_name" %} 39 | {{ report.verbose_name }}
40 | {% endfor %} 41 | {% endfor %} 42 |
43 |
44 |
45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/report.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load adminmedia admin_list i18n %} 3 | 4 | {% block extrastyle %} 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block extrahead %} 10 | 11 | 12 | 13 | 14 | {% endblock %} 15 | 16 | {% block bodyclass %}change-list{% endblock %} 17 | 18 | {% block breadcrumbs %} 19 | 25 | {% endblock %} 26 | 27 | 28 | {% block content %} 29 |
30 | {% block object-tools %}{% endblock %} 31 | 32 | {% block description %} 33 | {% if report.description %} 34 |

{% trans "Description" %}

35 |
{{ report.description }}
36 | {% endif %} 37 | {% endblock %} 38 | 39 | {% block filters %} 40 | {% if filter_form.fields %} 41 |

{% trans "Filters" %}

42 |
43 |
{% csrf_token %} 44 | 45 | {{ filter_form.as_table }} 46 |
47 | 48 |
49 |
50 | {% endif %} 51 | {% endblock %} 52 | 53 | {% block alternate-formats %} 54 |

{% trans "Alternate Formats" %}

55 |
56 | {% for of in report.output_formats %} 57 | {% ifequal of output_format %} 58 | {{ of.verbose_name }} {% if not forloop.last %}|{% endif %} 59 | {% else %} 60 | {{ of.verbose_name }} {% if not forloop.last %}|{% endif %} 61 | {% endifequal %} 62 | {% endfor %} 63 |
64 | {% endblock %} 65 | 66 |

Data

67 | 68 | 69 | 70 | {% for l in report.labels %} 71 | 72 | {% endfor %} 73 | 74 | 75 | 76 | {% for row in object_list %} 77 | 78 | {% for v in row %} 79 | 80 | {% endfor %} 81 | 82 | {% endfor %} 83 | {% for a in aggregates %} 84 | 85 | 86 | 87 | {% endfor %} 88 | 89 |
{{ l }}
{{ v }}
{{ a.0 }}{{ a.1 }}
90 | 91 | {% if cl %} 92 |
{% pagination cl %}
93 | {% endif %} 94 | 95 |
96 | {% endblock %} 97 | -------------------------------------------------------------------------------- /reportengine/templates/reportengine/request_report.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load adminmedia admin_list i18n %} 3 | 4 | {% block extrastyle %} 5 | 6 | 7 | {% endblock %} 8 | 9 | {% block extrahead %} 10 | 11 | 12 | 13 | 14 | 15 | {% endblock %} 16 | 17 | {% block bodyclass %}change-list{% endblock %} 18 | 19 | {% block breadcrumbs %} 20 | 25 | {% endblock %} 26 | 27 | 28 | {% block content %} 29 |
30 | {% block object-tools %}{% endblock %} 31 | 32 | {% block description %} 33 | {% if report.description %} 34 |

{% trans "Description" %}

35 |
{{ report.description }}
36 | {% endif %} 37 | {% endblock %} 38 | 39 | {% block filters %} 40 |

{% trans "Filters" %}

41 |
42 |
{% csrf_token %} 43 | 44 | {{ filter_form.as_table }} 45 |
46 | 47 |
48 |
49 | {% endblock %} 50 | 51 | {% block requested_reports %} 52 |

{% trans "Previously Requested Reports" %}

53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {% for report_request in requested_reports %} 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {% endfor %} 73 | 74 |
{% trans "Request Time" %}{% trans "Completion Time" %}{% trans "Params" %}{% trans "Link" %}{% trans "Status" %}
{{report_request.request_made}}{{report_request.completion_timestamp}}{{report_request.params}}{% trans "View Report" %}{{report_request.task_status}}
75 | {% endblock %} 76 |
77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /reportengine/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Report Engine Tests. 3 | """ 4 | from base import Report 5 | from filtercontrols import FilterControl 6 | from django.test import TestCase 7 | from django import forms 8 | import reportengine 9 | 10 | class BasicTestReport(Report): 11 | """Test Report. set the rows an aggregate to test""" 12 | slug="test" 13 | namespace="testing" 14 | verbose_name="Basic Test Report" 15 | 16 | def __init__(self, 17 | rows=[ [1,2,3] ], 18 | labels=["col1","col2","col3"], 19 | aggregate = (('total',1),), 20 | filterform = forms.Form() ): 21 | self.rows=rows 22 | self.labels=labels 23 | self.aggregate=aggregate 24 | self.filterform=filterform 25 | 26 | def get_rows(self,filters={},order_by=None): 27 | return self.rows,self.aggregate 28 | 29 | def get_filter_form(self,request): 30 | return self.filterform 31 | 32 | class BasicReportTest(TestCase): 33 | def test_report_register(self): 34 | """ 35 | Tests registering a report, and verifies report is now accessible 36 | """ 37 | r=BasicTestReport() 38 | reportengine.register(r) 39 | assert(reportengine.get_report("testing","test") == r) 40 | found=False 41 | for rep in reportengine.all_reports(): 42 | if rep[0] == (r.namespace,r.slug): 43 | assert(rep[1] == r) 44 | found=True 45 | assert(found) 46 | 47 | 48 | -------------------------------------------------------------------------------- /reportengine/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | urlpatterns = patterns('reportengine.views', 4 | # Listing of reports 5 | url('^$', 'report_list', name='reports-list'), 6 | 7 | # view report redirected to current date format (requires date_field argument) 8 | url('^current/(?P(day|week|month|year))/(?P[-\w]+)/(?P[-\w]+)/$', 9 | 'current_redirect', name='reports-current'), 10 | # view report redirected to current date format with formatting specified 11 | url('^current/(?P(day|week|month|year))/(?P[-\w]+)/(?P[-\w]+)/(?P[-\w]+)/$', 12 | 'current_redirect', name='reports-current-format'), 13 | # specify range of report per time (requires date_field) 14 | url('^date/(?P\d+)/(?P\d+)/(?P\d+)/(?P[-\w]+)/(?P[-\w]+)/$', 15 | 'day_redirect', name='reports-date-range'), 16 | # specify range of report per time with formatting 17 | url('^date/(?P\d+)/(?P\d+)/(?P\d+)/(?P[-\w]+)/(?P[-\w]+)/(?P[-\w]+)/$', 18 | 'day_redirect', name='reports-date-range-format'), 19 | # Show latest calendar of all date accessible reports 20 | url('^calendar/$', 'calendar_current_redirect', name='reports-calendar-current'), 21 | # Show specific month's calendar of reports 22 | url('^calendar/(?P\d+)/(?P\d+)/$', 'calendar_month_view', name='reports-calendar-month'), 23 | # Show specifi day's calendar of reports 24 | url('^calendar/(?P\d+)/(?P\d+)/(?P\d+)/$', 'calendar_day_view', name='reports-calendar-day'), 25 | 26 | ) 27 | 28 | 29 | urlpatterns += patterns('reportengine.views', 30 | # View report in first output style 31 | url('^request/(?P[-\w]+)/(?P[-\w]+)/$', 'request_report', name='reports-view'), 32 | # view report in specified output format 33 | #url('^request/(?P[-\w]+)/(?P[-\w]+)/(?P[-\w]+)/$', 'request_report', name='reports-view-format'), 34 | 35 | url('^view/(?P[\w\d]+)/$', 'view_report', name='reports-request-view'), 36 | # view report in specified output format 37 | url('^view/(?P[\w\d]+)/(?P[-\w]+)/$', 'view_report_export', name='reports-request-view-format'), 38 | ) 39 | 40 | 41 | -------------------------------------------------------------------------------- /reportengine/views.py: -------------------------------------------------------------------------------- 1 | from settings import ASYNC_REPORTS, MAX_ROWS_FOR_QUICK_EXPORT 2 | 3 | from django.shortcuts import render_to_response,redirect 4 | from django.template.context import RequestContext 5 | from django.contrib.admin.views.decorators import staff_member_required 6 | from django.core.urlresolvers import reverse 7 | from django.http import HttpResponseRedirect, Http404 8 | from django.conf import settings 9 | from django.views.generic import ListView, View, TemplateView 10 | from django.views.decorators.cache import never_cache 11 | 12 | import reportengine 13 | from reportengine.models import ReportRequest, ReportRequestExport 14 | from urllib import urlencode 15 | import datetime,calendar,hashlib 16 | 17 | def next_month(d): 18 | """helper to get next month""" 19 | return datetime.datetime(year=d.month<12 and d.year or d.year +1,month=d.month<12 and d.month+1 or 1,day=1) 20 | 21 | 22 | # TODO Maybe use a class based view? how do i make it easy to build SQLReports? 23 | @staff_member_required 24 | def report_list(request): 25 | # TODO make sure to constrain based upon permissions 26 | reports = [{'namespace': r.namespace, 'slug': r.slug, 'verbose_name': r.verbose_name} \ 27 | for s, r in reportengine.all_reports()] 28 | return render_to_response('reportengine/list.html', {'reports': reports}, 29 | context_instance=RequestContext(request)) 30 | 31 | class ReportRowQuery(object): 32 | def __init__(self, queryset): 33 | self.queryset = queryset 34 | 35 | def wrap(self, entry): 36 | return entry.data 37 | 38 | def __len__(self): 39 | return len(self.queryset) 40 | 41 | def count(self): 42 | return self.queryset.count() 43 | 44 | def __getitem__(self, val): 45 | if isinstance(val, slice): 46 | results = list() 47 | for entry in self.queryset[val]: 48 | results.append(self.wrap(entry)) 49 | return results 50 | else: 51 | return self.wrap(self.queryset[val]) 52 | 53 | class RequestReportMixin(object): 54 | asynchronous_report = ASYNC_REPORTS 55 | 56 | def check_report_status(self): 57 | #check to see if the report is not complete but async is off 58 | if not self.report_request.completion_timestamp and (not self.asynchronous_report or getattr(settings, 'CELERY_ALWAYS_EAGER', False)): 59 | self.report_request.build_report() 60 | assert self.report_request.completion_timestamp 61 | 62 | #check to see if the task failed 63 | if self.report_request.task_status() in ('FAILURE',): 64 | return {'error':'Task Failed', 'completed':False} 65 | 66 | return {'completed':bool(self.report_request.completion_timestamp)} 67 | 68 | ''' 69 | creport requested 70 | ''' 71 | 72 | class RequestReportView(TemplateView, RequestReportMixin): 73 | template_name = 'reportengine/request_report.html' 74 | 75 | def report_params(self): 76 | ''' 77 | Return report params without the output format 78 | ''' 79 | if hasattr(self, 'form'): 80 | return self.form.cleaned_data 81 | params = dict(self.request.POST.iteritems()) 82 | params.pop('output', None) 83 | params.pop('page', None) 84 | params.pop('_submit', None) 85 | params.pop('csrfmiddlewaretoken', None) 86 | return params 87 | 88 | def create_report_request(self): 89 | report_params = self.report_params() 90 | token_params = [str(datetime.datetime.now()), self.kwargs['namespace'], self.kwargs['slug'], urlencode(report_params)] 91 | token = hashlib.md5("|".join(token_params)).hexdigest() 92 | rr = ReportRequest(token=token, 93 | namespace=self.kwargs['namespace'], 94 | slug=self.kwargs['slug'], 95 | params=report_params) 96 | rr.save() 97 | return rr 98 | 99 | #CONSIDER inherit from a form view 100 | def get_report_class(self): 101 | return reportengine.get_report(self.kwargs['namespace'], self.kwargs['slug']) 102 | 103 | def get_form(self): 104 | report_cls = self.get_report_class() 105 | report = report_cls() 106 | if self.request.method.upper() == 'POST': 107 | form = report.get_filter_form(data=self.request.POST) 108 | else: 109 | form = report.get_filter_form(data=None) 110 | return form 111 | 112 | def get_requested_reports(self): 113 | qs = ReportRequest.objects.filter(namespace=self.kwargs['namespace'], 114 | slug=self.kwargs['slug'],) 115 | return qs 116 | 117 | def get_context_data(self, **kwargs): 118 | context = TemplateView.get_context_data(self, **kwargs) 119 | context['filter_form'] = self.get_form() 120 | context['report'] = self.get_report_class()() 121 | context['requested_reports'] = self.get_requested_reports() 122 | return context 123 | 124 | def create_and_redirect_to_report_request(self): 125 | self.report_request = self.create_report_request() 126 | self.report = self.report_request.get_report() 127 | if self.asynchronous_report: 128 | self.task = self.report_request.schedule_task() 129 | else: 130 | self.report_request.build_report() 131 | self.report_request = ReportRequest.objects.get(pk=self.report_request.pk) 132 | assert self.report_request.completion_timestamp 133 | return HttpResponseRedirect(self.report_request.get_absolute_url()) 134 | 135 | def get(self, request, *args, **kwargs): 136 | context = self.get_context_data(**kwargs) 137 | if not context['filter_form'].fields and not context['requested_reports']: 138 | return self.create_and_redirect_to_report_request() 139 | return self.render_to_response(context) 140 | 141 | def post(self, request, *args, **kwargs): 142 | self.form = self.get_form() 143 | if self.form.is_valid(): #TODO filter controls need to be optional 144 | return self.create_and_redirect_to_report_request() 145 | else: 146 | context = self.get_context_data(**kwargs) 147 | return self.render_to_response(context) 148 | 149 | request_report = never_cache(staff_member_required(RequestReportView.as_view())) 150 | 151 | class ReportView(ListView, RequestReportMixin): 152 | asynchronous_report = ASYNC_REPORTS 153 | paginate_by = 50 154 | 155 | def get_report_request(self): 156 | token = self.kwargs['token'] 157 | self.report_request = ReportRequest.objects.get(token=token) 158 | self.report = self.report_request.get_report() 159 | ReportRequest.objects.filter(pk=self.report_request.pk).update(viewed_on=datetime.datetime.now()) 160 | 161 | def get_queryset(self): 162 | return ReportRowQuery(self.report_request.rows.all()) 163 | 164 | def get_filter_form(self): 165 | filter_form = self.report.get_filter_form(self.request.REQUEST) 166 | return filter_form 167 | 168 | def get_changelist(self, info): 169 | paginator = info['paginator'] 170 | p = info['page_obj'] 171 | page = p.number 172 | rows = p.object_list 173 | order_by = None #TODO 174 | params = dict(self.request.GET.iteritems()) 175 | 176 | # HACK: fill up a fake ChangeList object to use the admin paginator 177 | class MiniChangeList: 178 | def __init__(self,paginator, page, params, report): 179 | self.paginator = paginator 180 | self.page_num = page-1 181 | self.show_all = report.can_show_all 182 | self.can_show_all = False 183 | self.multi_page = True 184 | self.params = params 185 | 186 | def get_query_string(self,new_params=None,remove=None): 187 | # Do I need to deal with new_params/remove? 188 | if remove != None: 189 | for k in remove: 190 | del self.params[k] 191 | if new_params != None: 192 | self.params.update(new_params) 193 | params = dict(self.params) 194 | if 'p' in params: 195 | params['page'] = params.pop('p') + 1 196 | return "?%s"%urlencode(params) 197 | 198 | cl_params = order_by and dict(params,order_by=order_by) or params 199 | cl = MiniChangeList(paginator, page, cl_params, self.report) 200 | return cl 201 | 202 | def get_context_data(self, **kwargs): 203 | data = ListView.get_context_data(self, **kwargs) 204 | data.update({'report': self.report, 205 | 'title':self.report.verbose_name, 206 | 'rows':self.object_list, 207 | 'filter_form':self.get_filter_form(), 208 | "aggregates":self.report_request.aggregates, 209 | "cl":self.get_changelist(data), 210 | 'report_request':self.report_request, 211 | "urlparams":urlencode(self.report_request.params)}) 212 | return data 213 | 214 | def get(self, request, *args, **kwargs): 215 | try: 216 | self.get_report_request() 217 | except ReportRequest.DoesNotExist: 218 | raise Http404() 219 | status = self.check_report_status() 220 | if 'error' in status: #there was an error, try recreating the report 221 | #CONSIDER add max retries 222 | return HttpResponseRedirect(self.report_request.get_report_url()) 223 | if not status['completed']: 224 | assert self.asynchronous_report 225 | cx = {"report_request":self.report_request, 226 | "report":self.report, 227 | 'title':self.report.verbose_name,} 228 | return render_to_response("reportengine/async_wait.html", 229 | cx, 230 | context_instance=RequestContext(self.request)) 231 | 232 | self.object_list = self.get_queryset() 233 | kwargs['object_list'] = self.object_list 234 | data = self.get_context_data(**kwargs) 235 | outputformat = None 236 | output = kwargs.get('output', 'admin') 237 | if output: 238 | for of in self.report.output_formats: 239 | if of.slug == output: 240 | outputformat=of 241 | if not outputformat: 242 | outputformat = self.report.output_formats[0] 243 | return outputformat.get_response(data, request) 244 | 245 | view_report = never_cache(staff_member_required(ReportView.as_view())) 246 | 247 | class ReportExportView(TemplateView, RequestReportMixin): 248 | asynchronous_report = ASYNC_REPORTS 249 | 250 | def get_report_request(self): 251 | token = self.kwargs['token'] 252 | self.report_request = ReportRequest.objects.get(token=token) 253 | self.report = self.report_request.get_report() 254 | ReportRequest.objects.filter(pk=self.report_request.pk).update(viewed_on=datetime.datetime.now()) 255 | 256 | def get_report_export_request(self): 257 | try: 258 | self.report_export_request = self.report_request.exports.get(format=self.kwargs['output']) 259 | except ReportRequestExport.DoesNotExist: 260 | self.report_export_request = ReportRequestExport(report_request=self.report_request, 261 | format=self.kwargs['output'], 262 | token=(self.report_request.token + self.kwargs['output']),) 263 | self.report_export_request.save() 264 | #TODO if the parent report is done and has under a certain number of rows, then no async is needed 265 | #however if opting the no-async route then it may not be necessary to create this object and upload the result to s3 266 | if self.asynchronous_report: 267 | self.task = self.report_export_request.schedule_task() 268 | else: 269 | self.report_export_request.build_report() 270 | 271 | def check_report_export_status(self): 272 | #check to see if the report is not complete but async is off 273 | if not self.report_export_request.completion_timestamp and (not self.asynchronous_report or getattr(settings, 'CELERY_ALWAYS_EAGER', False)): 274 | self.report_export_request.build_report() 275 | assert self.report_export_request.completion_timestamp 276 | 277 | #check to see if the task failed 278 | if self.report_export_request.task_status() in ('FAILURE',): 279 | return {'error':'Task Failed', 'completed':False} 280 | 281 | return {'completed':bool(self.report_export_request.completion_timestamp)} 282 | 283 | def get(self, request, *args, **kwargs): 284 | try: 285 | self.get_report_request() 286 | except ReportRequest.DoesNotExist: 287 | raise Http404() 288 | status = self.check_report_status() 289 | if 'error' in status: #there was an error, try recreating the report 290 | #CONSIDER add max retries 291 | return HttpResponseRedirect(self.report_request.get_report_url()) 292 | if not status['completed']: 293 | assert self.asynchronous_report 294 | cx = {"report_request":self.report_request, 295 | "report":self.report, 296 | 'title':self.report.verbose_name, 297 | 'format':self.kwargs['output'],} 298 | print cx 299 | return render_to_response("reportengine/async_wait.html", 300 | cx, 301 | context_instance=RequestContext(self.request)) 302 | 303 | #if the report is small enough there is no need to create a task to export 304 | if self.report_request.rows.all().count() <= MAX_ROWS_FOR_QUICK_EXPORT: 305 | return ReportView.as_view()(self.request, *self.args, **self.kwargs) 306 | 307 | self.get_report_export_request() 308 | status = self.check_report_export_status() 309 | if 'error' in status: #there was an error, try recreating the report 310 | #CONSIDER add max retries 311 | return HttpResponseRedirect(self.report_request.get_report_url()) 312 | if not status['completed']: 313 | assert self.asynchronous_report 314 | cx = {"report_request":self.report_request, 315 | "report":self.report, 316 | 'title':self.report.verbose_name, 317 | 'format':self.kwargs['output'],} 318 | return render_to_response("reportengine/async_wait.html", 319 | cx, 320 | context_instance=RequestContext(self.request)) 321 | return HttpResponseRedirect(self.report_export_request.payload.url) 322 | 323 | view_report_export = never_cache(staff_member_required(ReportExportView.as_view())) 324 | 325 | @staff_member_required 326 | def current_redirect(request, daterange, namespace, slug, output=None): 327 | # TODO make month and year more intelligent per calendar 328 | days={"day":1,"week":7,"month":30,"year":365} 329 | d2=datetime.datetime.now() 330 | d1=d2 - datetime.timedelta(days=days[daterange]) 331 | return redirect_report_on_date(request,d1,d2,namespace,slug,output) 332 | 333 | @staff_member_required 334 | def day_redirect(request, year, month, day, namespace, slug, output=None): 335 | year,month,day=int(year),int(month),int(day) 336 | d1=datetime.datetime(year=year,month=month,day=day) 337 | d2=d1 + datetime.timedelta(hours=24) 338 | return redirect_report_on_date(request,d1,d2,namespace,slug,output) 339 | 340 | def redirect_report_on_date(request,start_day,end_day,namespace,slug,output=None): 341 | """Utility that allows for a redirect of a report based upon the date range to the appropriate filter""" 342 | report=reportengine.get_report(namespace,slug) 343 | params = dict(request.REQUEST) 344 | if report.date_field: 345 | # TODO this only works with model fields, needs to be more generic 346 | dates = {"%s__gte"%report.date_field:start_day,"%s__lt"%report.date_field:end_day} 347 | params.update(dates) 348 | if output: 349 | return HttpResponseRedirect("%s?%s"%(reverse("reports-view-format",args=[namespace,slug,output]),urlencode(params))) 350 | return HttpResponseRedirect("%s?%s"%(reverse("reports-view",args=[namespace,slug]),urlencode(params))) 351 | 352 | @staff_member_required 353 | def calendar_current_redirect(request): 354 | d=datetime.datetime.today() 355 | return redirect("reports-calendar-month",year=d.year,month=d.month) 356 | 357 | @staff_member_required 358 | def calendar_month_view(request, year, month): 359 | # TODO make sure to constrain based upon permissions 360 | # TODO find all date_field accessible reports 361 | year,month=int(year),int(month) 362 | reports=[r[1] for r in reportengine.all_reports() if r[1].date_field] 363 | date=datetime.datetime(year=year,month=month,day=1) 364 | prev_month=date-datetime.timedelta(days=1) 365 | nxt_month=next_month(date) 366 | cal=calendar.monthcalendar(year,month) 367 | # TODO possibly pull in date based aggregates? 368 | cx={"reports":reports,"date":date,"calendar":cal,"prev":prev_month,"next":nxt_month} 369 | return render_to_response("reportengine/calendar_month.html",cx, 370 | context_instance=RequestContext(request)) 371 | 372 | @staff_member_required 373 | def calendar_day_view(request, year, month,day): 374 | # TODO make sure to constrain based upon permissions 375 | # TODO find all date_field accessible reports 376 | year,month,day=int(year),int(month),int(day) 377 | reports=[r[1] for r in reportengine.all_reports() if r[1].date_field] 378 | date=datetime.datetime(year=year,month=month,day=day) 379 | cal=calendar.monthcalendar(year,month) 380 | # TODO possibly pull in date based aggregates? 381 | cx={"reports":reports,"date":date,"calendar":cal} 382 | return render_to_response("reportengine/calendar_day.html",cx, 383 | context_instance=RequestContext(request)) 384 | 385 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from os.path import dirname, abspath 4 | 5 | from optparse import OptionParser 6 | 7 | from django.conf import settings, global_settings 8 | 9 | if not settings.configured: 10 | settings.configure( 11 | DATABASES={ 12 | 'default': { 13 | 'ENGINE': 'django.db.backends.sqlite3', 14 | } 15 | }, 16 | INSTALLED_APPS=[ 17 | 'django.contrib.auth', 18 | 'django.contrib.admin', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 'django.contrib.sites', 22 | 'reportengine', 23 | 'djcelery', 24 | 'tests', 25 | ], 26 | MIDDLEWARE_CLASSES=global_settings.MIDDLEWARE_CLASSES + ( 27 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 28 | ), 29 | ROOT_URLCONF='', 30 | DEBUG=False, 31 | SITE_ID=1, 32 | CELERY_ALWAYS_EAGER=True, 33 | ) 34 | 35 | from django.test.simple import DjangoTestSuiteRunner 36 | 37 | def runtests(*args, **kwargs): 38 | if not args: 39 | args = ['tests'] 40 | 41 | parent = dirname(abspath(__file__)) 42 | sys.path.insert(0, parent) 43 | test_runner = DjangoTestSuiteRunner( 44 | verbosity=kwargs.get('verbosity', 1), 45 | interactive=kwargs.get('interactive', False), 46 | failfast=kwargs.get('failfast') 47 | ) 48 | 49 | failures = test_runner.run_tests(args) 50 | sys.exit(failures) 51 | 52 | if __name__ == '__main__': 53 | parser = OptionParser() 54 | parser.add_option('--failfast', action='store_true', default=False, dest='failfast') 55 | (options, args)= parser.parse_args() 56 | runtests(failfast=options.failfast, *args) 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | VERSION = '0.3.1' 6 | LONG_DESC = """\ 7 | Django Report Engine provides a reporting framework for Django 1.3+. Its goal 8 | is to be lightweight, provide multiple output formats, easily integrate into 9 | existing applications, and be open ended to both direct SQL reports, ORM based 10 | reports, or any other type of report imaginable. It is also attempting to be 11 | reasonably batteries-included with some basic Date based filtering assumptions, 12 | and simple namespacing of reports. 13 | 14 | Reports are assumed to be tabular, with additional key/value "aggregates". A 15 | special form can be used to provide filtering/querying controls. There are 16 | premade filtering controls/framework to assist. Existing shortcut queryset and 17 | SQL based forms are integrated and can be quickly extended for generic reports. 18 | CSV, XML and HTML exports are included. 19 | """ 20 | 21 | setup( 22 | name='django-reportengine', 23 | version=VERSION, 24 | description="A Django app for building and integrating reports into your Django project.", 25 | long_description=LONG_DESC, 26 | classifiers=[ 27 | 'Programming Language :: Python', 28 | 'Operating System :: OS Independent', 29 | 'Natural Language :: English', 30 | 'Development Status :: 4 - Beta', 31 | 'Intended Audience :: Developers', 32 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 33 | ], 34 | keywords='django reporting reports', 35 | maintainer = 'Nikolaj Baer', 36 | maintainer_email = 'nikolaj@cukerinteractive.com', 37 | url='http://github.com/cuker/django-reportengine', 38 | license='MIT License', 39 | packages=find_packages(exclude=['example', 'example.example_reports', 'tests']), 40 | tests_require=[ 41 | 'django>=1.3,<1.6', 42 | 'factory_boy', 43 | 'django-celery', 44 | ], 45 | zip_safe=False, 46 | install_requires=[ ], 47 | test_suite='runtests.runtests', 48 | include_package_data = True, 49 | ) 50 | 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | 5 | class Address(models.Model): 6 | street = models.TextField() 7 | city = models.CharField(max_length=64) 8 | state = models.CharField(max_length=2) 9 | postal_code = models.CharField(max_length=10) 10 | customer = models.ForeignKey("Customer", related_name='shipping_addresses') 11 | 12 | class PaymentInfo(models.Model): 13 | #TODO - Not really real. This will be randomly generated by 14 | # factory_boy 15 | payment_token = models.CharField(max_length=16) 16 | 17 | class BillingInfo(models.Model): 18 | address = models.ForeignKey(Address) 19 | payment_info = models.ForeignKey(PaymentInfo) 20 | 21 | class Customer(models.Model): 22 | first_name = models.CharField(max_length=64) 23 | last_name = models.CharField(max_length=64) 24 | stamp = models.DateTimeField() 25 | age = models.IntegerField() 26 | 27 | 28 | class Sale(models.Model): 29 | customer = models.ForeignKey(Customer) 30 | ship_address = models.ForeignKey(Address) 31 | bill_info = models.ForeignKey(BillingInfo) 32 | 33 | purchase_date = models.DateTimeField() 34 | 35 | total = models.DecimalField(max_digits=6,decimal_places=2) 36 | tax_rate = models.DecimalField(max_digits=4, decimal_places=2) 37 | 38 | 39 | class SaleItem(models.Model): 40 | sale = models.ForeignKey(Sale) 41 | 42 | department = models.CharField(max_length=20) 43 | classification = models.CharField(max_length=20) 44 | sub_classification = models.CharField(max_length=20) 45 | name = models.CharField(max_length=64) 46 | price = models.DecimalField(max_digits=10,decimal_places=2) 47 | option1 = models.CharField(max_length=64) 48 | option2 = models.CharField(max_length=64) 49 | 50 | -------------------------------------------------------------------------------- /tests/reports.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Kevin Mooney' 2 | from reportengine import base, register 3 | from reportengine.filtercontrols import StartsWithFilterControl 4 | from models import Customer, SaleItem 5 | 6 | 7 | class CustomerReport(base.ModelReport): 8 | """An example of a model report""" 9 | verbose_name = "User Report" 10 | slug = "user-report" 11 | namespace = "system" 12 | description = "Listing of all Customers in the system" 13 | labels = ('stamp','first_name','last_name') 14 | list_filter=['is_active','date_joined',StartsWithFilterControl('username'),'groups'] 15 | date_field = "stamp" 16 | model=Customer 17 | per_page = 500 18 | 19 | register(CustomerReport) 20 | 21 | 22 | class CustomerSalesReport(base.SQLReport): 23 | """A SQL Report to show sales by customer""" 24 | 25 | verbose_name = "Sales Report By Person" 26 | allow_unspecified_filters = True 27 | slug = "sale-report" 28 | namespace = "system" 29 | description = "A listing of all sales reports" 30 | 31 | labels = ('first_name', 'last_name', 'total') 32 | 33 | list_filter=['first_name', 'last_name'] 34 | 35 | row_sql = """ 36 | SELECT first_name, last_name, SUM(total) as total FROM tests_sale 37 | INNER JOIN tests_customer ON tests_sale.customer_id = tests_customer.id 38 | WHERE first_name = '%(first_name)s' AND last_name = '%(last_name)s' 39 | GROUP BY tests_customer.id 40 | ORDER BY total 41 | """ 42 | register(CustomerSalesReport) 43 | 44 | 45 | class SaleItemReport(base.QuerySetReport): 46 | verbose_name = "Sales Items, filtered by customer" 47 | 48 | slug = "sales-item-report" 49 | namespace = "system" 50 | description = "A listing of all sales items." 51 | 52 | labels = ('name', 'option1', 'option2', 'price') 53 | 54 | def get_queryset(self, *args, **kwargs): 55 | return SaleItem.objects.all() 56 | 57 | 58 | class CustomerByStamp(base.DateSQLReport): 59 | """A Date SQL report to show customers by timestamp""" 60 | 61 | slug = "customer-by-stamp" 62 | namespace = "system" 63 | description = "A list of all customers bracketed by start and end dates" 64 | 65 | labels = ('first_name', 'last_name', 'stamp') 66 | 67 | row_sql = """ 68 | SELECT first_name, last_name, stamp FROM tests_customer 69 | WHERE stamp < '%(date__lt)s' AND stamp >= '%(date__gte)s'; 70 | """ 71 | 72 | 73 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | #from reportengine.base import Report, QuerySetReport, ModelReport, SQLReport, DateSQLReport 2 | 3 | #from django.conf import settings 4 | #from django.contrib.auth.models import User 5 | #from django.http import HttpResponse 6 | #from django.template import Template, Context 7 | import decimal 8 | from django.test import TestCase 9 | #from django.test import RequestFactory 10 | import factory 11 | import models 12 | import time 13 | from reports import CustomerReport, CustomerSalesReport, SaleItemReport, CustomerByStamp 14 | from datetime import datetime, timedelta 15 | from utils import first_names, last_names 16 | import random 17 | import reportengine 18 | import json 19 | from reportengine.outputformats import CSVOutputFormat 20 | 21 | class CustomerFactory(factory.Factory): 22 | FACTORY_FOR = models.Customer 23 | first_name = 'Default' 24 | last_name = 'Customer' 25 | stamp = datetime.now() 26 | age = 33 27 | 28 | class PaymentInfoFactory(factory.Factory): 29 | FACTORY_FOR = models.PaymentInfo 30 | payment_token = '4111111111111111' 31 | 32 | class AddressFactory(factory.Factory): 33 | FACTORY_FOR = models.Address 34 | street = '123 Happy Street' 35 | city = 'Austin' 36 | state = 'TX' 37 | postal_code = '78704' 38 | customer = factory.LazyAttribute(lambda a: CustomerFactory()) 39 | 40 | class BillingInfoFactory(factory.Factory): 41 | FACTORY_FOR = models.BillingInfo 42 | address = factory.LazyAttribute(lambda a: AddressFactory()) 43 | payment_info = factory.LazyAttribute(lambda a: PaymentInfoFactory()) 44 | 45 | 46 | class SaleFactory(factory.Factory): 47 | FACTORY_FOR = models.Sale 48 | 49 | customer = factory.LazyAttribute(lambda a: CustomerFactory()) 50 | ship_address = factory.LazyAttribute(lambda a: AddressFactory()) 51 | bill_info = factory.LazyAttribute(lambda a: BillingInfoFactory()) 52 | total = decimal.Decimal("100.00") 53 | tax_rate = decimal.Decimal("0.08") 54 | purchase_date = datetime.now() 55 | 56 | class SaleItemFactory(factory.Factory): 57 | FACTORY_FOR = models.SaleItem 58 | sale = factory.LazyAttribute(lambda a: SaleFactory()) 59 | department = "Mens" 60 | classification = "Apparel" 61 | sub_classification = "Gloves" 62 | name = "Isotoners - For Men!" 63 | option1 = "Blue" 64 | option2 = "Medium" 65 | price = decimal.Decimal("25.00") 66 | 67 | 68 | 69 | class BaseTestCase(TestCase): 70 | sql_filters = {} 71 | test_total = decimal.Decimal("0.00") 72 | this_months_customers = 0 73 | def setUp(self): 74 | for i in range(0,100): 75 | c = CustomerFactory( 76 | first_name=random.choice(first_names), 77 | last_name=random.choice(last_names), 78 | stamp=datetime.now() - timedelta(days=random.randint(1,365*4)), 79 | age = random.randint(13,90) 80 | ) 81 | if c.stamp >= (datetime.now() - timedelta(days=30)): 82 | self.this_months_customers += 1 83 | if i == 1: 84 | self.sql_filters['first_name'] = c.first_name 85 | self.sql_filters['last_name'] = c.last_name 86 | address = AddressFactory(customer=c) 87 | billing_info = BillingInfoFactory(address=address) 88 | sale = SaleFactory(customer=c, ship_address=address, bill_info=billing_info) 89 | final_price = decimal.Decimal("0.00") 90 | for j in range(random.randint(0,5)): 91 | si = SaleItemFactory(sale=sale) 92 | final_price += si.price 93 | sale.total = final_price + (final_price * sale.tax_rate) 94 | if i == 1 or (c.first_name == self.sql_filters.get('first_name', '') and 95 | c.last_name == self.sql_filters.get('last_name', '')): 96 | self.test_total += sale.total 97 | sale.save() 98 | 99 | def tearDown(self): 100 | models.Customer.objects.all().delete() 101 | 102 | 103 | class ReportEngineTestCase(BaseTestCase): 104 | 105 | def test_reportregistration(self): 106 | len(reportengine._registry) 107 | self.assertTrue(len(reportengine._registry) == 2) 108 | self.assertTrue( (CustomerReport.namespace, CustomerReport.slug) in reportengine._registry ) 109 | 110 | def test_modelreport(self): 111 | cr = CustomerReport() 112 | rows,metadata = cr.get_rows() 113 | self.assertTrue(len(rows)==models.Customer.objects.count()) 114 | 115 | def test_sqlreport(self): 116 | csr = CustomerSalesReport() 117 | rows, metadata = csr.get_rows(self.sql_filters) 118 | self.assertEqual(rows[0][2], self.test_total) 119 | 120 | def test_querysetreport(self): 121 | sir = SaleItemReport() 122 | rows, metadata = sir.get_rows() 123 | self.assertEqual(models.SaleItem.objects.count(), len(rows)) 124 | 125 | def test_datesqlreport(self): 126 | cbs = CustomerByStamp() 127 | # shouldn't be required to get default mask. it should be the... default 128 | rows,metadata = cbs.get_rows(filters={'date__lt': (datetime.now()+timedelta(days=1)).strftime('%Y-%m-%d'), 129 | 'date__gte': (datetime.now()-timedelta(days=30)).strftime('%Y-%m-%d') 130 | }) 131 | 132 | self.assertEqual(len(rows), self.this_months_customers) 133 | 134 | def test_report(self): 135 | class CounterReport(reportengine.base.Report): 136 | def get_rows(self, *args, **kwargs): 137 | return [(x,) for x in range(0,10000)], ('total', 10000,) 138 | cr = CounterReport() 139 | rows, metadata = cr.get_rows() 140 | self.assertTrue(len(rows), dict((metadata,))['total']) 141 | 142 | def test_fastcsvresponse(self): 143 | """ 144 | This should test that the CSV conversion from stored report data for 10,000 lines (100,000?) will 145 | take less than 15 seconds. 146 | """ 147 | class RandomNameReport(reportengine.base.Report): 148 | labels = ('number', 'first_name', 'last_name',) 149 | def get_rows(self, *args, **kwargs): 150 | return [ 151 | (x, random.choice(first_names), random.choice(last_names), ) for x in range(0,100000) 152 | ], ('total', 100000,) 153 | rnr = RandomNameReport() 154 | csv = CSVOutputFormat() 155 | ctx = dict() 156 | rows, metadata = rnr.get_rows() 157 | ctx['rows'] = rows 158 | ctx['aggregates'] = [] 159 | ctx['report'] = rnr 160 | 161 | then = time.clock() 162 | csv.get_response(ctx,None) 163 | now = time.clock() 164 | result = now - then 165 | 166 | self.assertLessEqual(result,15) 167 | 168 | 169 | def test_reportrequest(self): 170 | from reportengine.models import ReportRequest 171 | rr = ReportRequest.objects.create(namespace='system', slug='sale-report', params=json.dumps(self.sql_filters)) 172 | #ALWAYS_EAGER = True, so should run right away. 173 | result = rr.schedule_task() 174 | self.assertEqual(True,result.successful()) 175 | 176 | def test_reportexportrequest(self): 177 | from reportengine.models import ReportRequest 178 | rr = ReportRequest.objects.create(namespace='system', slug='sale-report', params=json.dumps(self.sql_filters)) 179 | #ALWAYS_EAGER = True, so should run right away. 180 | result = rr.schedule_task() 181 | self.assertEqual(True,result.successful()) 182 | 183 | 184 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/tests/urls.py -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Kevin Mooney' 2 | 3 | first_names = [ 4 | 'Kevin', 5 | 'Bob', 6 | 'Mark', 7 | 'Aaron', 8 | 'Terence', 9 | 'Floyd', 10 | 'John', 11 | 'Steve', 12 | 'Ronald', 13 | 'Jeffery', 14 | 'Alan', 15 | 'Sarah', 16 | 'Rebecca', 17 | 'Michael', 18 | 'Jason', 19 | 'Nicolas', 20 | 'Amy', 21 | 'Chris', 22 | 'Doug', 23 | 'Karen', 24 | 'Lou Ann', 25 | 'Steven', 26 | 'Dave', 27 | 'David', 28 | 'Jeff', 29 | 'Melissa', 30 | 'Robert', 31 | 'Rob', 32 | 'Stephen', 33 | 'Andrew', 34 | 'Jake', 35 | 'Jack', 36 | 'Frederick', 37 | 'Jackson', 38 | 'Will', 39 | 'William', 40 | 'Joseph', 41 | 'Edward', 42 | 'Jacob', 43 | 'Quinn', 44 | 'Kurt', 45 | 'Alexandar', 46 | 'Alex', 47 | 'Thomas', 48 | 'Tommy', 49 | 'Tom', 50 | 'Kathy', 51 | 'Kate', 52 | 'Kaitlin', 53 | 'Monica', 54 | 'Monika', 55 | 'Kenneth', 56 | 'Kenny', 57 | 'Ken', 58 | 'Timothy', 59 | 'Tim', 60 | ] 61 | 62 | last_names = [ 63 | 'Smith', 64 | 'Jones', 65 | 'Jackson', 66 | 'Washington', 67 | 'Jefferson', 68 | 'Madison', 69 | 'Monroe', 70 | 'Munroe', 71 | 'Adams', 72 | 'Van Buren', 73 | 'Harrison', 74 | 'Tyler', 75 | 'Polk', 76 | 'Taylor', 77 | 'Fillmore', 78 | 'Pierce', 79 | 'Buchanan', 80 | 'Lincoln', 81 | 'Johnson', 82 | 'Grant', 83 | 'Hayes', 84 | 'Garfield', 85 | 'Arthur', 86 | 'Cleveland', 87 | 'McKinley', 88 | 'Roosevelt', 89 | ] -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cuker/django-reportengine/f9de8f9ea0b580b087df8c416fc328deedcdbb1c/tests/views.py -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,py26 3 | 4 | [testenv] 5 | commands=python setup.py test 6 | 7 | --------------------------------------------------------------------------------