├── .gitignore ├── AUTHORS.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.rst ├── admin_reports ├── __init__.py ├── apps.py ├── decorators.py ├── forms.py ├── reports.py ├── sites.py ├── static │ └── admin_report │ │ └── css │ │ └── reportlist.css ├── templates │ └── admin │ │ ├── export.html │ │ ├── report.html │ │ └── report_totals.html └── views.py ├── debian ├── changelog ├── compat ├── control ├── gbp.conf ├── pydist-overrides ├── rules └── source │ └── format └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | \#* 4 | .\#* 5 | *.egg-info 6 | /dist/ -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | * Antonio Corroppoli 5 | * Dario Pavone 6 | * Marco Pattaro 7 | * Matteo Atti 8 | * Michele Totaro 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Simplyopen s.r.l. and individual contributors. 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 django-admin-reports nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without 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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.rst 3 | include AUTHORS.rst 4 | include MANIFEST.in 5 | 6 | recursive-exclude *.pyc 7 | recursive-exclude *~ 8 | recursive-exclude \#* 9 | recursive-exclude \.#* 10 | 11 | recursive-include admin_reports/templates *.html 12 | recursive-include admin_reports/static * 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env make -f 2 | PYTHON=/usr/bin/env python 3 | PIP=/usr/bin/env pip 4 | DJANGO_ADMIN=/usr/bin/env django-admin 5 | 6 | .PHONY: all clean sdist upload install-dev install 7 | 8 | all: sdist 9 | 10 | clean: 11 | rm -fr dist *.egg-info build 12 | find . \( -name "*.py[co]" -o -name "*.mo" -o -name "*~" \) -type f -delete 13 | 14 | sdist: clean 15 | $(PIP) install wheel 16 | $(PYTHON) setup.py sdist bdist_wheel 17 | 18 | upload: clean 19 | $(PYTHON) setup.py sdist upload 20 | 21 | install-dev: 22 | $(PIP) install -e . 23 | 24 | install: 25 | $(PYTHON) setup.py install 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/django-admin-reports.svg 2 | :target: https://pypi.python.org/pypi/django-admin-reports 3 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 4 | :target: https://github.com/psf/black 5 | 6 | ==================== 7 | django-admin-reports 8 | ==================== 9 | 10 | Overview 11 | ******** 12 | 13 | "admin_reports" is a Django application to easily create data 14 | aggregation reports to display inside Django admin. 15 | 16 | The Django admin is very much centered on models and it provide a 17 | quick and simple way to create a GUI for the CRUD interface, but 18 | often there's the need to display data in an aggregate form, here's 19 | where admin_reports comes handy. 20 | 21 | The idea is to have a class similar to ``ModelAdmin`` (from 22 | ``django.contrib.admin``) that allow to display derived data 23 | concentrating on implementing the aggregation procedure. 24 | 25 | Basic Usage 26 | *********** 27 | 28 | First of all add ``admin_reports`` to your project's ``INSTALLED_APPS`` settings 29 | variable; it's important that it is after ``django.contrib.admin``. 30 | 31 | Basically admin_reports provide your Django site with an abstract view 32 | ``Report``. All you need to do is give an implementation to the 33 | abstract method ``aggregate()``. The important thing is that this 34 | method must return a list of dictionaries, a Queryset or a 35 | ``pandas.Dataframe`` (https://github.com/pydata/pandas). 36 | 37 | A stupid example could be this: :: 38 | 39 | from admin_reports import Report, register 40 | 41 | @register() 42 | class MyReport(Report): 43 | def aggregate(self, **kwargs): 44 | return [ 45 | dict([(k, v) for v, k in enumerate('abcdefgh')]), 46 | dict([(k, v) for v, k in enumerate('abcdefgh')]),.. 47 | ] 48 | 49 | 50 | Then in your django site ``urls.py`` add the following: :: 51 | 52 | from django.contrib import admin 53 | import admin_reports 54 | 55 | urlpatterns = patterns( 56 | ... 57 | url(r'^admin/', include(admin.site.urls)), 58 | url(r'^admin/', include(admin_reports.site.urls)), 59 | ... 60 | ) 61 | 62 | The auto generate urls will be a lowercase version of 63 | your class name. 64 | 65 | So for the example above:: 66 | 67 | /admin/myapp/myreport/ 68 | 69 | The urlname to be passed to ``reverse`` will be the underscored 70 | version of your class name, so with the above example:: 71 | 72 | 'admin_reports:my_report' 73 | 74 | 75 | Passing parameters to ``aggregate`` 76 | =================================== 77 | 78 | Most of the times you'll need to pass parameters to ``aggregate``, you 79 | can do so by the association of a Form class to your Report: all the 80 | form fields will be passed to ``aggregate`` as keyword arguments, then 81 | it's up to you what do with them.:: 82 | 83 | from django import forms 84 | from admin_reports import Report 85 | 86 | 87 | class MyReportForm(forms.Form): 88 | from_date = forms.DateField(label="From") 89 | to_date = forms.DateField(label="To") 90 | 91 | 92 | class MyReport(Report): 93 | form_class = MyReportForm 94 | 95 | def aggregate(self, from_date=None, to_date=None, **kwargs): 96 | # Write yout aggregation here 97 | return ret 98 | 99 | 100 | The Report class 101 | **************** 102 | 103 | The ``Report`` class is projected to be flexible and let you modify 104 | various aspect of the final report. 105 | 106 | Attributes 107 | ========== 108 | 109 | As for the ``ModelAdmin`` the most straightforward way of changing the 110 | behavior of your subclasses is to override the public class 111 | attributes; anyway for each of these attributes there is a 112 | ``get_`` method hook to override in order to alter behaviors at 113 | run-time. 114 | 115 | Report.fields 116 | ------------- 117 | 118 | This is a list of field names that you want to be used as columns in 119 | your report, the default is ``None`` and means that the ``get_fields`` 120 | method will try to guess them from the results of your ``aggregate`` 121 | implementation. 122 | 123 | The ``fields`` attribute can contain names of callables. This 124 | methods are supposed to receive a record of the report as a 125 | parameter.:: 126 | 127 | class MyReport(Report): 128 | 129 | fields = [ 130 | ..., 131 | 'pretty_value', 132 | ... 133 | ] 134 | 135 | def pretty_value(self, record): 136 | return do_something_fancy_with(record['my_column']) 137 | 138 | For this callables the ``allow_tags`` attribute can be set to ``True`` 139 | if they are supposed to return an HTML string. 140 | 141 | Fields labels 142 | ^^^^^^^^^^^^^ 143 | 144 | When a field name is provided alone in the ``fields`` attribute 145 | ``admin_reports`` will generate a label for you in the rendered 146 | table. If you want to provide a custom label just enter a tuple of two 147 | elements instead of just the field name, ``(field_name, label)``. 148 | 149 | Report.formatting 150 | ----------------- 151 | 152 | The ``formatting`` attribute is a dictionary that lets you specify the 153 | formatting function to use for each field.:: 154 | 155 | class MyReport(Report): 156 | 157 | formatting = { 158 | 'amount': lambda x: format(x, ',.2f'), 159 | } 160 | 161 | Report.has_totals 162 | ----------------- 163 | 164 | This attribute is a boolean to tell whether the last record of your 165 | aggregation is to be considered as a row of totals, in this case it 166 | will be displayed highlighted on every page. 167 | 168 | Report.totals_on_top 169 | -------------------- 170 | 171 | Whether to display an eventual record of totals in on top of the 172 | table, if ``False`` it will be displayed on bottom. 173 | 174 | This attribute has no effect if ``Report.has_totals`` is ``False``. 175 | 176 | Report.title 177 | ------------ 178 | 179 | A string to use as the page title. 180 | 181 | Report.description 182 | ------------------ 183 | 184 | A short description to explain the meaning of the report. 185 | 186 | Report.help_text 187 | ---------------- 188 | 189 | A longer description of the report, meant to explain the meaning of 190 | each single field. 191 | 192 | Report.template_name 193 | -------------------- 194 | 195 | The template to use to render the report as an html page (default: 196 | ``admin/report.html``). 197 | 198 | Report.paginator 199 | ---------------- 200 | 201 | The class to use a ``Paginator``. 202 | 203 | Report.list_per_page 204 | -------------------- 205 | 206 | ``list_per_page`` parameter passed to the ``Paginator`` class. 207 | 208 | Report.list_max_show_all 209 | ------------------------ 210 | 211 | ``list_max_show_all`` parameter passed to the ``Paginator`` class. 212 | 213 | Report.alignment 214 | ---------------- 215 | 216 | How to align values in columns when rendering the html table, a 217 | dictionary that associates to each field one of the following values 218 | (``aling-left``, ``align-center``, ``align-right``). 219 | 220 | Report.form_class 221 | ----------------- 222 | 223 | The ``Form`` class to use to pass parameter to the ``aggregate`` method. 224 | 225 | Report.export_form_class 226 | ------------------------ 227 | 228 | The ``Form`` class to use to pass parameter to the ``to_csv`` method. 229 | 230 | Report.initial 231 | -------------- 232 | 233 | Initial values for the ``form_class``. 234 | -------------------------------------------------------------------------------- /admin_reports/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .decorators import register 3 | from .reports import Report 4 | from .sites import site 5 | except ImportError: 6 | pass 7 | else: 8 | __all__ = ["register", "Report", "site"] 9 | 10 | __version__ = "0.11.0" 11 | default_app_config = "admin_reports.apps.AdminReportConfig" 12 | -------------------------------------------------------------------------------- /admin_reports/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.module_loading import autodiscover_modules 3 | from .sites import site 4 | 5 | 6 | class AdminReportConfig(AppConfig): 7 | name = "admin_reports" 8 | 9 | def autodiscover(self): 10 | autodiscover_modules("reports", register_to=site) 11 | 12 | def ready(self): 13 | self.autodiscover() 14 | -------------------------------------------------------------------------------- /admin_reports/decorators.py: -------------------------------------------------------------------------------- 1 | def register(): 2 | from .sites import site 3 | 4 | def _report_wrapper(report_class): 5 | site.register(report_class) 6 | return report_class 7 | 8 | return _report_wrapper 9 | -------------------------------------------------------------------------------- /admin_reports/forms.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from django import forms 3 | 4 | delimiters = ";,|:" 5 | quotes = "\"'`" 6 | escapechars = " \\" 7 | 8 | 9 | class ExportForm(forms.Form): 10 | """ Let an admin user costomize a CSV export. 11 | """ 12 | 13 | header = forms.BooleanField(required=False, initial=True) 14 | totals = forms.BooleanField(required=False, initial=True) 15 | delimiter = forms.ChoiceField(choices=zip(delimiters, delimiters)) 16 | quotechar = forms.ChoiceField(choices=zip(quotes, quotes)) 17 | quoting = forms.ChoiceField( 18 | choices=( 19 | (csv.QUOTE_NONNUMERIC, "Non Numeric"), 20 | (csv.QUOTE_NONE, "None"), 21 | (csv.QUOTE_MINIMAL, "Minimal"), 22 | (csv.QUOTE_ALL, "All"), 23 | ) 24 | ) 25 | escapechar = forms.ChoiceField(choices=(("", ""), ("\\", "\\")), required=False) 26 | 27 | def clean_quoting(self): 28 | quoting = self.cleaned_data.get("quoting") 29 | if quoting: 30 | return int(quoting) 31 | 32 | def clean_delimiter(self): 33 | delimiter = self.cleaned_data.get("delimiter") 34 | if delimiter: 35 | return str(delimiter) 36 | 37 | def clean_quotechar(self): 38 | quotechar = self.cleaned_data.get("quotechar") 39 | if quotechar: 40 | return str(quotechar) 41 | 42 | def clean_escapechar(self): 43 | escapechar = self.cleaned_data.get("escapechar") 44 | if escapechar: 45 | return str(escapechar) 46 | else: 47 | return "" 48 | -------------------------------------------------------------------------------- /admin_reports/reports.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import logging 5 | import six 6 | import csv 7 | import re 8 | from django.conf import settings 9 | 10 | try: 11 | from django.db.models.query import QuerySet, ValuesQuerySet 12 | except ImportError: 13 | # django >= 1.9 does not have ValuesQuerySet anymore 14 | from django.db.models.query import QuerySet, ModelIterable 15 | from django.utils.safestring import mark_safe 16 | from django.core.paginator import Paginator 17 | 18 | try: 19 | pnd = True 20 | from pandas import DataFrame 21 | except ImportError: 22 | pnd = False 23 | from .forms import ExportForm 24 | 25 | logger = logging.getLogger(__name__) 26 | camel_re = re.compile("([a-z0-9])([A-Z])") 27 | 28 | 29 | class Report(object): 30 | fields = None 31 | formatting = None 32 | has_totals = False 33 | totals_on_top = False 34 | title = None 35 | description = "" 36 | help_text = "" 37 | template_name = "admin/report.html" 38 | paginator = Paginator # ReportPaginator 39 | list_per_page = 100 40 | list_max_show_all = 200 41 | alignment = None 42 | form_class = None 43 | export_form_class = ExportForm 44 | initial = {} 45 | auto_totals = None 46 | 47 | def __init__(self, *args, **kwargs): 48 | self.set_sort_params() 49 | self.set_params(**self.get_initial()) 50 | self._data_type = "list" 51 | self._results = [] 52 | self._totals = {} 53 | 54 | def __len__(self): 55 | if not self._evaluated: 56 | self._eval() 57 | if self._data_type == "qs": 58 | return self._results.count() 59 | elif self._data_type == "df": 60 | return self._results.index.size 61 | return len(self._results) 62 | 63 | def _split_totals(self, results): 64 | if self.has_totals and (len(results) > 0) and (self.auto_totals is None): 65 | if pnd and (self._data_type == "df"): 66 | self._results = results.iloc[:-1] 67 | self._totals = results.iloc[-1] 68 | elif self._data_type == "qs": 69 | self._results = results.exclude(pk=results.last().pk) 70 | self._totals = results.last().__dict__ 71 | else: 72 | length = len(results) 73 | self._results = results[: length - 1] 74 | self._totals = results[length - 1] 75 | self._evaluated_totals = True 76 | else: 77 | self._results = results 78 | self._totals = {} 79 | 80 | def _sort_results(self): 81 | if self._data_type == "qs": 82 | if self._sort_params: 83 | self._results = self._results.order_by(*self._sort_params) 84 | elif self._data_type == "df": 85 | columns = [] 86 | ascending = [] 87 | for param in self._sort_params: 88 | if param.startswith("-"): 89 | ascending.append(0) 90 | columns.append(param.replace("-", "", 1)) 91 | else: 92 | ascending.append(1) 93 | columns.append(param) 94 | if columns: 95 | self._results = self._results.sort_values(columns, ascending=ascending) 96 | else: 97 | for param in reversed(self._sort_params): 98 | reverse = False 99 | if param.startswith("-"): 100 | reverse = True 101 | param = param.replace("-", "", 1) 102 | self._results = sorted( 103 | self._results, key=lambda x: x[param], reverse=reverse 104 | ) 105 | self._sorted = True 106 | 107 | def _eval(self): 108 | results = self.aggregate(**self._params) 109 | try: 110 | values = isinstance(results, ValuesQuerySet) 111 | except NameError: # django >= 1.9 112 | values = results.__class__ is not ModelIterable 113 | if isinstance(results, QuerySet) and not values: 114 | self._data_type = "qs" 115 | elif pnd and isinstance(results, DataFrame): 116 | self._data_type = "df" 117 | self._split_totals(results) 118 | self._evaluated = True 119 | 120 | def _eval_totals(self): 121 | if self._data_type == "qs": 122 | # TODO 123 | pass 124 | elif pnd and self._data_type == "df": 125 | self._totals = self._results.agg(self.auto_totals) 126 | else: 127 | for field_name, _ in self.get_fields(): 128 | func = self.auto_totals.get(field_name, False) 129 | if func: 130 | self._totals[field_name] = func( 131 | [row[field_name] for row in self._results] 132 | ) 133 | # else: 134 | # self._totals[field_name] = "" 135 | self._evaluated_totals = True 136 | 137 | def _items(self, record): 138 | for field_name, _ in self.get_fields(): 139 | # Does the field_name refer to an aggregation column or is 140 | # it an attribute of this instance? 141 | try: 142 | attr_field = getattr(self, field_name) 143 | except AttributeError: 144 | # The field is a record element 145 | ret = record.get(field_name) 146 | formatting_func = self.get_formatting().get(field_name) 147 | if formatting_func is not None: 148 | try: 149 | ret = formatting_func(ret) 150 | except (TypeError, ValueError): 151 | pass 152 | else: 153 | # The view class has an attribute with this field_name 154 | if callable(attr_field): 155 | ret = attr_field(record) 156 | if getattr(attr_field, "allow_tags", False): 157 | ret = mark_safe(ret) 158 | yield ret 159 | 160 | def reset(self): 161 | self._sorted = False 162 | self._evaluated = False 163 | self._evaluated_totals = False 164 | 165 | def get_results(self): 166 | if not self._evaluated: 167 | self._eval() 168 | if not self._sorted: 169 | self._sort_results() 170 | if self._data_type == "qs": 171 | if not self._is_value_qs(self._results): 172 | return self._results.values() 173 | else: 174 | return self._results 175 | elif self._data_type == "df": 176 | return self._results.to_dict(orient="records") 177 | return self._results 178 | 179 | def get_totals(self): 180 | if self.has_totals: 181 | if not self._evaluated: 182 | self._eval() 183 | if not self._evaluated_totals and self.auto_totals is not None: 184 | self._eval_totals() 185 | if self._data_type == "qs": 186 | totals_dict = dict(self._totals) 187 | elif self._data_type == "df": 188 | totals_dict = self._totals.to_dict() 189 | else: 190 | totals_dict = self._totals 191 | return { 192 | field_name: totals_dict.get(field_name, "") 193 | for field_name, _ in self.get_fields() 194 | } 195 | 196 | def get_formatting(self): 197 | if self.formatting is not None: 198 | return self.formatting 199 | return {} 200 | 201 | def get_alignment(self, field): 202 | if self.alignment is None: 203 | return "align-left" 204 | else: 205 | try: 206 | return self.alignment[field] 207 | except KeyError: 208 | return "align-left" 209 | 210 | def _is_value_qs(self, results): 211 | if hasattr(results.query, "values_select"): 212 | return results.query.values_select 213 | return [] 214 | 215 | def get_fields(self): 216 | if not self._evaluated: 217 | self._eval() 218 | if self.fields is None: 219 | if self._data_type == "df": 220 | self.fields = self._results.columns 221 | elif self._data_type == "qs": 222 | values = self._is_value_qs(self._results) 223 | if not values: 224 | values = self._is_value_qs(self._results.values()) 225 | self.fields = ( 226 | values 227 | + self._results.query.annotations.keys() 228 | + self._results.query.extra.keys() 229 | ) 230 | else: 231 | try: 232 | self.fields = self.get_results()[0].keys() 233 | except IndexError: 234 | self.fields = [] 235 | return [ 236 | field 237 | if isinstance(field, (list, tuple)) 238 | else (field, " ".join([s.title() for s in field.split("_")])) 239 | for field in self.fields 240 | ] 241 | 242 | def set_params(self, **kwargs): 243 | self._params = kwargs 244 | self._evaluated = False 245 | self._evaluated_totals = False 246 | 247 | def set_sort_params(self, *sort_params): 248 | self._sort_params = tuple(sort_params) 249 | self._sorted = False 250 | 251 | def get_sort_params(self): 252 | return tuple(self._sort_params) 253 | 254 | sort_params = property(get_sort_params, set_sort_params) 255 | 256 | def get_initial(self): 257 | return self.initial 258 | 259 | def get_form_class(self): 260 | return self.form_class 261 | 262 | def get_title(self): 263 | if self.title is None: 264 | return camel_re.sub(r"\1 \2", self.__class__.__name__).capitalize() 265 | return self.title 266 | 267 | def get_help_text(self): 268 | return mark_safe(self.help_text) 269 | 270 | def get_description(self): 271 | return mark_safe(self.description) 272 | 273 | def get_has_totals(self): 274 | return self.has_totals 275 | 276 | def get_paginator(self): 277 | return self.paginator(self.results, self.get_list_per_page()) 278 | 279 | def get_list_max_show_all(self): 280 | return self.list_max_show_all 281 | 282 | def get_list_per_page(self): 283 | return self.list_per_page 284 | 285 | def get_export_form_class(self): 286 | return self.export_form_class 287 | 288 | def iter_results(self): 289 | for record in self.get_results(): 290 | yield self._items(record) 291 | 292 | @property 293 | def results(self): 294 | return [tuple(elem for elem in record) for record in self.iter_results()] 295 | 296 | def iter_totals(self): 297 | return self._items(self.get_totals()) 298 | 299 | @property 300 | def totals(self): 301 | return tuple(elem for elem in self.iter_totals()) 302 | 303 | def aggregate(self, **kwargs): 304 | """ Implement here your data elaboration. 305 | Must return a list of dict. 306 | """ 307 | raise NotImplementedError("Subclasses must implement this method") 308 | 309 | def to_csv( 310 | self, 311 | fileobj, 312 | header=False, 313 | totals=False, 314 | delimiter=";", 315 | quotechar='"', 316 | quoting=csv.QUOTE_NONNUMERIC, 317 | escapechar="", 318 | extra_rows=None, 319 | **kwargs 320 | ): 321 | writer = csv.writer( 322 | fileobj, 323 | delimiter=str(delimiter), 324 | quotechar=str(quotechar), 325 | quoting=quoting, 326 | escapechar=str(escapechar), 327 | **kwargs 328 | ) 329 | if extra_rows is not None: 330 | writer.writerows(extra_rows) 331 | if header: 332 | if six.PY2: 333 | writer.writerow( 334 | [ 335 | name.encode(settings.DEFAULT_CHARSET) 336 | for name, _ in self.get_fields() 337 | ] 338 | ) 339 | else: 340 | writer.writerow([name for name, _ in self.get_fields()]) 341 | for record in self.iter_results(): 342 | if six.PY2: 343 | writer.writerow( 344 | [ 345 | elem.encode(settings.DEFAULT_CHARSET) 346 | if isinstance(elem, six.text_type) 347 | else elem 348 | for elem in record 349 | ] 350 | ) 351 | else: 352 | writer.writerow(record) 353 | if totals and self.get_has_totals(): 354 | writer.writerow(self.totals) 355 | 356 | def has_permission(self, request): 357 | return request.user.is_active and request.user.is_staff 358 | -------------------------------------------------------------------------------- /admin_reports/sites.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import apps 4 | from django.conf.urls import url 5 | from django.contrib.admin.sites import site as admin_site 6 | from .views import ReportView 7 | from .reports import Report, camel_re 8 | 9 | 10 | class AlreadyRegistered(Exception): 11 | pass 12 | 13 | 14 | class NotRegistered(Exception): 15 | pass 16 | 17 | 18 | class AdminReportSite(object): 19 | def __init__(self, name="admin_reports"): 20 | self.name = name 21 | self._registry = [] 22 | 23 | def register(self, report): 24 | if issubclass(report, Report): 25 | if report in self._registry: 26 | raise AlreadyRegistered( 27 | "The report %s is already registered" % report.__name__ 28 | ) 29 | self._registry.append(report) 30 | 31 | def unregister(self, report): 32 | if issubclass(report, Report): 33 | if report not in self._registry: 34 | raise NotRegistered("The report %s is not registered" % report.__name__) 35 | self._registry.remove(report) 36 | 37 | def get_urls(self): 38 | urlpatterns = [] 39 | 40 | for report in self._registry: 41 | app_name = apps.get_containing_app_config(report.__module__).name 42 | urlpatterns.append( 43 | url( 44 | r"^{0}/{1}/$".format( 45 | app_name.replace(".", "_"), report.__name__.lower() 46 | ), 47 | admin_site.admin_view(ReportView.as_view(report_class=report)), 48 | name=camel_re.sub(r"\1_\2", report.__name__).lower(), 49 | ) 50 | ) 51 | return urlpatterns 52 | 53 | @property 54 | def urls(self): 55 | return self.get_urls(), "admin_reports", self.name 56 | 57 | 58 | site = AdminReportSite() 59 | -------------------------------------------------------------------------------- /admin_reports/static/admin_report/css/reportlist.css: -------------------------------------------------------------------------------- 1 | .table td.align-right { 2 | text-align: right; 3 | } 4 | 5 | .table td.align-left { 6 | text-align: left; 7 | } 8 | 9 | .table td.align-center { 10 | text-align: center; 11 | } -------------------------------------------------------------------------------- /admin_reports/templates/admin/export.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |
{% csrf_token %} 5 |
6 | {{ form }} 7 |
8 |    9 |
10 | 11 | {% trans 'Go back to report' %} 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /admin_reports/templates/admin/report.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls static admin_list %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | {% comment %} 7 | {% if suit %} 8 | 9 | {% endif %} 10 | {% endcomment %} 11 | {% if not suit %} 12 | 13 | {% endif %} 14 | {% if has_filters %} 15 | 16 | 17 | {{ form.media.css }} 18 | {% endif %} 19 | 20 | {{ media.css }} 21 | {% endblock %} 22 | 23 | {% block extrahead %} 24 | {{ block.super }} 25 | {{ media.js }} 26 | {% if has_filters %} 27 | {{ form.media.js }} 28 | {% endif %} 29 | {% endblock %} 30 | 31 | {% block bodyclass %}report{% endblock %} 32 | 33 | {% block coltype %}flex{% endblock %} 34 | 35 | {% block content %} 36 |
37 |
38 | 39 | {% block title_text %} 40 | {% if suit %} 41 |
42 |

{{ title }}

43 |
44 | {% endif %} 45 | 46 |
47 | {% if description %}

{{ description }}

{% endif %} 48 | {% if help_text %}See more{% endif %} 49 |
50 | 51 | 54 | {% endblock %} 55 | 56 |

 

57 | 58 |
59 | 60 |
61 | {% block search %} 62 | 79 | {% trans 'Export' %} 80 | {% endblock %} 81 |
82 | 83 |
84 | {% block result %} 85 | 86 | 87 | 88 | {% for header in rl.headers %} 89 | 106 | {% endfor %} 107 | 108 | 109 | 110 | {% if totals and totals_on_top %} 111 | {% include "admin/report_totals.html" %} 112 | {% endif %} 113 | 114 | 115 | {% for result in rl.results %} 116 | 117 | {% for alignment, item in result %} 118 | 119 | {% endfor %} 120 | 121 | {% endfor %} 122 | 123 | 124 | {% if totals and not totals_on_top %} 125 | {% include "admin/report_totals.html" %} 126 | {% endif %} 127 | 128 |
90 | {% if header.sortable %} 91 | {% if header.sort_priority > 0 %} 92 | {% if suit %}
{% endif %} 93 |
94 | 95 | {% if rl.num_sorted_fields > 1 %}{{ header.sort_priority }}{% endif %} 96 | 97 | 98 | 99 | {% if suit %}
{% endif %} 100 |
101 | {% endif %} 102 | {% endif %} 103 |
{% if header.sortable %}{{ header.label|capfirst }}{% else %}{{ header.label|capfirst }}{% endif %}
104 |
105 |
{{ item }}
129 | {% endblock %} 130 |
131 | 132 | {% block pagination %}{% pagination rl %}{% endblock %} 133 | 134 |
135 |
136 |
137 | {% endblock %} 138 | -------------------------------------------------------------------------------- /admin_reports/templates/admin/report_totals.html: -------------------------------------------------------------------------------- 1 | {% block totals %} 2 | 3 | 4 | {% for alignment, total in rl.totals %} 5 | {{ total }} 6 | {% endfor %} 7 | 8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /admin_reports/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import logging 5 | from collections import OrderedDict 6 | 7 | from django import forms 8 | from django.apps import apps 9 | from django.conf import settings 10 | from django.core.paginator import InvalidPage 11 | from django.core.exceptions import PermissionDenied, ImproperlyConfigured 12 | from django.views.generic.edit import FormMixin 13 | from django.views.generic import TemplateView 14 | from django.http import HttpResponse 15 | from django.utils.html import format_html 16 | from django.shortcuts import render 17 | 18 | try: 19 | # Django 2 20 | from django.contrib.staticfiles.templatetags.staticfiles import static 21 | except ModuleNotFoundError: 22 | # Django 3 23 | from django.templatetags.static import static 24 | from django.contrib.admin.options import IncorrectLookupParameters 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | ALL_VAR = "all" 29 | ORDER_VAR = "o" 30 | PAGE_VAR = "p" 31 | EXPORT_VAR = "e" 32 | CONTROL_VARS = [ALL_VAR, ORDER_VAR, PAGE_VAR, EXPORT_VAR] 33 | 34 | 35 | class ReportList(object): 36 | def __init__(self, request, report): 37 | self.request = request 38 | self.report = report 39 | self.ordering_field_columns = self._get_ordering_field_columns() 40 | self.report.set_sort_params(*self._get_ordering()) 41 | self.multi_page = False 42 | self.can_show_all = True 43 | self.paginator = None # self.report.get_paginator() 44 | try: 45 | self.page_num = int(self.request.GET.get(PAGE_VAR, 0)) 46 | except ValueError: 47 | self.page_num = 0 48 | self.show_all = ALL_VAR in self.request.GET 49 | 50 | def get_query_string(self, new_params=None, remove=None): 51 | if new_params is None: 52 | new_params = {} 53 | if remove is None: 54 | remove = [] 55 | params = self.request.GET.copy() 56 | for r in remove: 57 | for k in params.iterkeys(): 58 | if k.startswith(r): 59 | del params[k] 60 | 61 | for k, v in new_params.items(): 62 | if v is None: 63 | if k in params: 64 | del params[k] 65 | else: 66 | params[k] = v 67 | return "?%s" % params.urlencode() 68 | 69 | def _get_ordering(self): 70 | ordering = [] 71 | order_params = self.request.GET.get(ORDER_VAR) 72 | if order_params: 73 | sort_values = order_params.split(".") 74 | fields = self.report.get_fields() 75 | for o in sort_values: 76 | if o.startswith("-"): 77 | field = "-%s" % fields[int(o.replace("-", ""))][0] 78 | else: 79 | field = fields[int(o)][0] 80 | ordering.append(field) 81 | return ordering 82 | 83 | def _get_ordering_field_columns(self): 84 | """ 85 | Returns an OrderedDict of ordering field column numbers and asc/desc 86 | """ 87 | # We must cope with more than one column having the same underlying sort 88 | # field, so we base things on column numbers. 89 | ordering = [] 90 | ordering_fields = OrderedDict() 91 | if ORDER_VAR not in self.request.GET: 92 | # for ordering specified on ModelAdmin or model Meta, we don't know 93 | # the right column numbers absolutely, because there might be more 94 | # than one column associated with that ordering, so we guess. 95 | for field in ordering: 96 | if field.startswith("-"): 97 | field = field[1:] 98 | order_type = "desc" 99 | else: 100 | order_type = "asc" 101 | for index, attr in enumerate(self.list_display): 102 | if self.get_ordering_field(attr) == field: 103 | ordering_fields[index] = order_type 104 | break 105 | else: 106 | for p in self.request.GET[ORDER_VAR].split("."): 107 | _, pfx, idx = p.rpartition("-") 108 | try: 109 | idx = int(idx) 110 | except ValueError: 111 | continue # skip it 112 | ordering_fields[idx] = "desc" if pfx == "-" else "asc" 113 | return ordering_fields 114 | 115 | def headers(self): 116 | fields = self.report.get_fields() 117 | for i, field in enumerate(fields): 118 | name = field[0] 119 | label = field[1] 120 | if callable(getattr(self.report, name, name)): 121 | yield { 122 | "label": label, 123 | "class_attrib": format_html(' class="column-{0}"', name), 124 | "sortable": False, 125 | } 126 | continue 127 | th_classes = ["sortable", "column-{0}".format(name)] 128 | order_type = "" 129 | new_order_type = "asc" 130 | sort_priority = 0 131 | sorted_ = False 132 | # Is it currently being sorted on? 133 | if i in self.ordering_field_columns: 134 | sorted_ = True 135 | order_type = self.ordering_field_columns.get(i).lower() 136 | sort_priority = list(self.ordering_field_columns).index(i) + 1 137 | th_classes.append("sorted %sending" % order_type) 138 | new_order_type = {"asc": "desc", "desc": "asc"}[order_type] 139 | # build new ordering param 140 | o_list_primary = [] # URL for making this field the primary sort 141 | o_list_remove = [] # URL for removing this field from sort 142 | o_list_toggle = [] # URL for toggling order type for this field 143 | make_qs_param = lambda t, n: ("-" if t == "desc" else "") + str(n) 144 | for j, ot in self.ordering_field_columns.items(): 145 | if j == i: # Same column 146 | param = make_qs_param(new_order_type, j) 147 | # We want clicking on this header to bring the ordering to the 148 | # front 149 | o_list_primary.insert(0, param) 150 | o_list_toggle.append(param) 151 | # o_list_remove - omit 152 | else: 153 | param = make_qs_param(ot, j) 154 | o_list_primary.append(param) 155 | o_list_toggle.append(param) 156 | o_list_remove.append(param) 157 | if i not in self.ordering_field_columns: 158 | o_list_primary.insert(0, make_qs_param(new_order_type, i)) 159 | yield { 160 | "label": label, 161 | "sortable": True, 162 | "sorted": sorted_, 163 | "ascending": order_type == "asc", 164 | "sort_priority": sort_priority, 165 | "url_primary": self.get_query_string( 166 | {ORDER_VAR: ".".join(o_list_primary)} 167 | ), 168 | "url_remove": self.get_query_string( 169 | {ORDER_VAR: ".".join(o_list_remove)} 170 | ), 171 | "url_toggle": self.get_query_string( 172 | {ORDER_VAR: ".".join(o_list_toggle)} 173 | ), 174 | "class_attrib": format_html(' class="{0}"', " ".join(th_classes)) 175 | if th_classes 176 | else "", 177 | } 178 | 179 | @property 180 | def totals(self): 181 | fields = self.report.get_fields() 182 | for idx, value in enumerate(self.report.iter_totals()): 183 | yield (self.report.get_alignment(fields[idx][0]), value) 184 | 185 | @property 186 | def results(self): 187 | fields = self.report.get_fields() 188 | for record in self.paginate(): 189 | yield [ 190 | (self.report.get_alignment(fields[idx][0]), value) 191 | for idx, value in enumerate(record) 192 | ] 193 | 194 | def get_result_count(self): 195 | return len(self.report) 196 | 197 | def paginate(self): 198 | records = self.report.results 199 | self.paginator = self.report.get_paginator() 200 | result_count = self.paginator.count 201 | self.multi_page = result_count > self.report.get_list_per_page() 202 | self.can_show_all = result_count <= self.report.get_list_max_show_all() 203 | if not (self.show_all and self.can_show_all) and self.multi_page: 204 | try: 205 | records = self.paginator.page(self.page_num + 1).object_list 206 | except InvalidPage: 207 | raise IncorrectLookupParameters 208 | return records 209 | 210 | 211 | class Opts(object): 212 | def __init__(self, report): 213 | self._report = report 214 | module = self._report.__class__.__module__ 215 | app_config = apps.get_containing_app_config(module) 216 | self._app_label = app_config.label 217 | self._object_name = self._report.__class__.__name__ 218 | 219 | def get_app_label(self): 220 | return self._app_label 221 | 222 | app_label = property(get_app_label) 223 | 224 | def get_object_name(self): 225 | return self._object_name 226 | 227 | object_name = property(get_object_name) 228 | 229 | 230 | class ReportView(TemplateView, FormMixin): 231 | 232 | report_class = None 233 | 234 | def __init__(self, report_class, *args, **kwargs): 235 | super(ReportView, self).__init__(*args, **kwargs) 236 | self.report_class = report_class 237 | self.report = None 238 | 239 | def get_initial(self): 240 | initial = super(ReportView, self).get_initial() 241 | initial.update(self.report.get_initial()) 242 | return initial 243 | 244 | @property 245 | def media(self): 246 | # taken from django.contrib.admin.options ModelAdmin 247 | extra = "" if settings.DEBUG else ".min" 248 | js = [ 249 | "core.js", 250 | "vendor/jquery/jquery%s.js" % extra, 251 | "jquery.init.js", 252 | "admin/RelatedObjectLookups.js", 253 | "actions%s.js" % extra, 254 | "urlify.js", 255 | "prepopulate%s.js" % extra, 256 | "vendor/xregexp/xregexp%s.js" % extra, 257 | ] 258 | return forms.Media(js=[static("admin/js/%s" % url) for url in js]) 259 | 260 | def _export(self, form=None): 261 | if form is None: 262 | form = self.get_export_form() 263 | ctx = { 264 | "form": form, 265 | "back": "?%s" 266 | % "&".join( 267 | [ 268 | "%s=%s" % param 269 | for param in self.request.GET.items() 270 | if param[0] != EXPORT_VAR 271 | ] 272 | ), 273 | } 274 | return render(self.request, "admin/export.html", ctx) 275 | 276 | def get_report_class(self): 277 | if self.report_class is None: 278 | raise ImproperlyConfigured( 279 | "You must specify `report_class` or override `get_report_class`" 280 | ) 281 | return self.report_class 282 | 283 | def get_report_args(self): 284 | return [] 285 | 286 | def get_report_kwargs(self): 287 | return {} 288 | 289 | def get_report(self, report_class=None): 290 | if report_class is None: 291 | report_class = self.get_report_class() 292 | return report_class(*self.get_report_args(), **self.get_report_kwargs()) 293 | 294 | def post(self, request, *args, **kwargs): 295 | self.report = self.get_report() 296 | if not self.report.has_permission(self.request): 297 | raise PermissionDenied() 298 | form = self.get_export_form(data=self.request.POST) 299 | if form.is_valid(): 300 | context = self.get_context_data(**kwargs) 301 | filename = context["title"].lower().replace(" ", "_") 302 | response = HttpResponse(content_type="text/csv") 303 | response["Content-Disposition"] = 'attachment;filename="%s.csv"' % filename 304 | self.report.to_csv(response, **form.cleaned_data) 305 | return response 306 | return self._export(form=form) 307 | 308 | def get(self, request, *args, **kwargs): 309 | self.report = self.get_report() 310 | if not self.report.has_permission(request): 311 | raise PermissionDenied() 312 | if EXPORT_VAR in request.GET: 313 | return self._export() 314 | return super(ReportView, self).get(request, *args, **kwargs) 315 | 316 | def get_form_kwargs(self): 317 | kwargs = super(ReportView, self).get_form_kwargs() 318 | if self.request.method in ("GET", "POST"): 319 | form_data = self.request.GET.copy() 320 | for key in CONTROL_VARS: 321 | if key in form_data: 322 | del form_data[key] 323 | if form_data: 324 | kwargs.update({"data": form_data}) 325 | else: 326 | kwargs.update({"data": kwargs["initial"]}) 327 | return kwargs 328 | 329 | def get_form(self, form_class=None): 330 | if form_class is None: 331 | # If there's no form... there's no form. 332 | return None 333 | return super(ReportView, self).get_form(form_class) 334 | 335 | def get_form_class(self): 336 | return self.report_class.form_class 337 | 338 | def get_context_data(self, **kwargs): 339 | kwargs = super(ReportView, self).get_context_data(**kwargs) 340 | kwargs["media"] = self.media 341 | form = self.get_form(self.get_form_class()) 342 | if form is not None: 343 | kwargs["form"] = form 344 | if form.is_valid(): 345 | self.report.set_params(**form.cleaned_data) 346 | rl = ReportList(self.request, self.report) 347 | kwargs.update( 348 | { 349 | "rl": rl, 350 | "opts": Opts(self.report), 351 | "title": self.report.get_title(), 352 | "has_filters": self.get_form_class() is not None, 353 | "help_text": self.report.get_help_text(), 354 | "description": self.report.get_description(), 355 | "export_path": rl.get_query_string({EXPORT_VAR: ""}), 356 | "totals": self.report.get_has_totals(), 357 | "totals_on_top": self.report.totals_on_top, 358 | "suit": ( 359 | ("suit" in settings.INSTALLED_APPS) 360 | or ("bootstrap_admin" in settings.INSTALLED_APPS) 361 | ), 362 | } 363 | ) 364 | return kwargs 365 | 366 | def get_template_names(self): 367 | return self.report.template_name 368 | 369 | def get_export_form(self, form_class=None, **kwargs): 370 | if form_class is None: 371 | form_class = self.report.get_export_form_class() 372 | return form_class(**kwargs) 373 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | django-admin-reports (0.10.8) unstable; urgency=medium 2 | 3 | [ Marco Pattaro ] 4 | * debian/changelog updated 5 | 6 | [ Ruslan Okopny ] 7 | * Fix bug with multi-select field 8 | 9 | [ Marco Pattaro ] 10 | * removed self.params attr from ReportList (useless) 11 | 12 | -- Marco Pattaro Wed, 04 Oct 2017 11:31:52 +0200 13 | 14 | django-admin-reports (0.10.7) unstable; urgency=medium 15 | 16 | [ Ruslan Okopny ] 17 | * Fix ValueError when order param is blank 18 | 19 | [ Marco Pattaro ] 20 | * update .gitignore 21 | * bump new version 22 | 23 | -- Marco Pattaro Tue, 26 Sep 2017 09:54:21 +0200 24 | 25 | django-admin-reports (0.10.6) unstable; urgency=medium 26 | 27 | [ maxicecilia ] 28 | * Use render in favor of render_to_response to support django 1.8+ 29 | 30 | -- Marco Pattaro Fri, 15 Sep 2017 09:31:07 +0200 31 | 32 | django-admin-reports (0.10.5) unstable; urgency=medium 33 | 34 | [ Marco Pattaro ] 35 | * fix some compatibility issues 36 | * Other compatibility issues 37 | * fix version checking 38 | * clean up 39 | 40 | [ Antonio Corroppoli ] 41 | * fix generated url 42 | * fix set_params property 43 | * fix sort results for queryset 44 | 45 | -- Marco Pattaro Tue, 29 Aug 2017 11:53:56 +0200 46 | 47 | django-admin-reports (0.10.4) unstable; urgency=medium 48 | 49 | * update setup.py 50 | * update README 51 | * fix has_totals when workign with QuerySet and ValuesQuerySet 52 | * update readme 53 | * fix sorting in QuerySet case 54 | * auto_totals basic idea 55 | * ValuesQuerySet not present anymore in Django>=1.9 56 | * pass get() and post() arguments to Report.__init__() 57 | * apply correct table style with bootsrap_admin 58 | 59 | -- Marco Pattaro Fri, 24 Feb 2017 15:27:23 +0100 60 | 61 | django-admin-reports (0.10.3) unstable; urgency=medium 62 | 63 | * drop dependency from ValueQuerySet 64 | * form with django 1.9 FormMixin 65 | 66 | -- Marco Pattaro Tue, 09 Aug 2016 11:38:03 +0200 67 | 68 | django-admin-reports (0.10.2) unstable; urgency=medium 69 | 70 | * update setup.py to read version from __init__.py 71 | * fix Report.get_results() in Queryset Case 72 | * Handle different interface between QuerySet and ValuesQuerySet 73 | * update report.html template 74 | 75 | -- Marco Pattaro Mon, 25 Jul 2016 10:51:11 +0200 76 | 77 | django-admin-reports (0.10.1) unstable; urgency=medium 78 | 79 | * update readme 80 | * register reports to autogenerate url patterns 81 | * remove print 82 | * add PyPI badge 83 | * update .gitignore 84 | * better default fields 85 | * update README 86 | * cleaner url registration syntax 87 | * update readme 88 | * autodiscover 89 | * has_permission accept request instead of user object 90 | 91 | -- Marco Pattaro Mon, 18 Jul 2016 11:56:46 +0200 92 | 93 | django-admin-reports (0.10.0) unstable; urgency=medium 94 | 95 | * move much of the report logic in Report class, while the ReportView and ReportList should be responsible only of the web communication and rendering 96 | * to_csv extra_rows 97 | 98 | -- Marco Pattaro Thu, 30 Jun 2016 18:06:52 +0200 99 | 100 | django-admin-reports (0.9.4) unstable; urgency=medium 101 | 102 | * correct charset encoding when exporting to csv 103 | * update csv export form 104 | * fix get_results in Queryset case 105 | 106 | -- Marco Pattaro Thu, 23 Jun 2016 17:08:54 +0200 107 | 108 | django-admin-reports (0.9.3) unstable; urgency=medium 109 | 110 | * check user permissions 111 | * fix csv export 112 | 113 | -- Marco Pattaro Tue, 31 May 2016 12:51:31 +0200 114 | 115 | django-admin-reports (0.9.2) unstable; urgency=medium 116 | 117 | * udpate manifest 118 | 119 | -- Marco Pattaro Wed, 25 May 2016 15:18:25 +0200 120 | 121 | django-admin-reports (0.9.1) unstable; urgency=medium 122 | 123 | * handle if suit is in INSTALLED_APPS 124 | * handle table's columns alignment 125 | 126 | -- Marco Pattaro Wed, 25 May 2016 13:52:40 +0200 127 | 128 | django-admin-reports (0.9.0) unstable; urgency=medium 129 | 130 | * admin_report 131 | * use dict_filter to save some iterations on the reports result 132 | * ReportView handle function fields 133 | * ReportView: field's labels 134 | * ReportView: fix QuerySet case 135 | * ReportView: correct media 136 | * ReportView: behave correctly without a form. 137 | * ReportView: title and help text 138 | * ReportView: pagination and styling 139 | * ReportView: sorting 140 | * ReportView: fix automatic fileds 141 | * ReportView: fix behavior on empty results 142 | * ReportView: better aggregate() parameters 143 | * ReportView: handle allow_tags method 144 | * ReportView: correct get_list_max_show_all 145 | * admin_reports: formatting functions 146 | * admin_report: csv export 147 | * admin_reports: small fixes 148 | * add totals to reports 149 | * improve admin_report's totals row and allow to specify total's row placement 150 | * cleanup 151 | * mv in subfolder 152 | * add setup.py 153 | * add .gitignore 154 | * add package_data in setuppy 155 | * add debian folder 156 | * Add AUTHORS,LICENSE and README 157 | * add dh-python to Build-Depends 158 | 159 | -- Marco Pattaro Wed, 04 May 2016 16:49:17 +0200 160 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: django-admin-reports 2 | Section: python 3 | Priority: extra 4 | Build-Depends: debhelper(>= 9), python-setuptools, python (>= 2.6.6-3~), dh-python 5 | Maintainer: SimplyOpen 6 | Standards-Version: 3.9.4 7 | X-Python-Version: >= 2.6 8 | 9 | Package: python-django-admin-reports 10 | Architecture: all 11 | Depends: ${python:Depends} 12 | Suggests: python-pandas 13 | Description: Reports for django-admin 14 | Easily define and show data analysis reports for django-admin 15 | -------------------------------------------------------------------------------- /debian/gbp.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | debian-branch = master 3 | debian-tag = v%(version)s 4 | pristine-tar = False 5 | -------------------------------------------------------------------------------- /debian/pydist-overrides: -------------------------------------------------------------------------------- 1 | Django python-django;PEP386 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ --with python2 5 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | from admin_reports import __version__ as version_string 3 | 4 | setup( 5 | name='django-admin-reports', 6 | version=version_string, 7 | description='Reports for django-admin', 8 | author='Simplyopen SRL', 9 | author_email='info@simplyopen.org', 10 | url='https://github.com/simplyopen-it/django-admin-reports', 11 | classifiers=[ 12 | 'Development Status :: 4 - Beta', 13 | 'Framework :: Django', 14 | "Intended Audience :: Developers", 15 | 'License :: OSI Approved :: MIT License', 16 | 'Operating System :: OS Independent', 17 | 'Programming Language :: Python', 18 | 'Programming Language :: Python :: 2.6', 19 | 'Programming Language :: Python :: 2.7', 20 | 'Programming Language :: Python :: 3.5', 21 | 'Programming Language :: Python :: 3.6', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application' 25 | ], 26 | packages=find_packages(), 27 | include_package_data=True, 28 | zip_safe=False, 29 | install_requires=[ 30 | 'Django>=1.11', 31 | 'pandas>=0.18', 32 | ] 33 | ) 34 | --------------------------------------------------------------------------------