diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..3066fb4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,14 @@
+repos:
+
+- repo: https://github.com/adamchainz/blacken-docs
+ rev: ""
+ hooks:
+ - id: blacken-docs
+ additional_dependencies:
+ - black==22.12.0
+
+- repo: https://github.com/psf/black
+ rev: stable
+ hooks:
+ - id: black
+ language_version: python3.9
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4168d9d..f70582d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,31 @@
All notable changes to this project will be documented in this file.
+## [0.9.0] - 2023-06-07
+
+- Deprecated ``form_factory`` in favor of ``forms``, to be removed next version.
+- Deprecated `crosstab_model` in favor of ``crosstab_field``, to be removed next version.
+- Deprecated ``slick_reporting.view.SlickReportView`` and ``slick_reporting.view.SlickReportViewBase`` in favor of ``slick_reporting.view.ReportView`` and ``slick_reporting.view.BaseReportView``, to be removed next version.
+- Allowed cross tab on fields other than ForeignKey
+- Added support for start_date_field_name and end_date_field_name
+- Added support to crosstab on traversing fields
+- Added support for document types / debit and credit calculations
+- Added support for ordering via ``ReportView.default_order_by`` and/or passing the parameter ``order_by`` to the view
+- Added return of Ajax response in case of error and request is Ajax
+- Made it easy override to the search form. Create you own form and subclass BaseReportForm and implement the mandatory method(s).
+- Consolidated the needed resources in ``slick_reporting/js_resource.html`` template, so to use your own template you just need to include it.
+- Fixed an issue with report fields not respecting the queryset on the ReportView.
+- Fixed an issue if a foreign key have a custom `to_field` set either in ``group_by`` and/or `crosstab_field` .
+- Enhancing and adding to the documentation.
+- Black format the code and the documentation
+
+
## [0.8.0]
- Breaking: [Only if you use Crosstab reports] renamed crosstab_compute_reminder to crosstab_compute_remainder
- Breaking : [Only if you set the templates statics by hand] renamed slick_reporting to ra.hightchart.js and ra.chartjs.js to
erp_framework.highchart.js and erp_framework.chartjs.js respectively
+- Fix an issue with Crosstab when there crosstab_compute_remainder = False
## [0.7.0]
diff --git a/README.rst b/README.rst
index d3505a6..7c06eb0 100644
--- a/README.rst
+++ b/README.rst
@@ -51,32 +51,36 @@ You can simply use a code like this
.. code-block:: python
# in your urls.py
- path('path-to-report', TotalProductSales.as_view())
+ path("path-to-report", TotalProductSales.as_view())
# in views.py
from django.db.models import Sum
- from slick_reporting.views import SlickReportView
+ from slick_reporting.views import ReportView
from slick_reporting.fields import SlickReportField
from .models import MySalesItems
- class TotalProductSales(SlickReportView):
- report_model = MySalesItems
- date_field = 'date_placed'
- group_by = 'product'
- columns = ['title',
- SlickReportField.create(Sum, 'quantity') ,
- SlickReportField.create(Sum, 'value', name='sum__value') ]
-
- chart_settings = [{
- 'type': 'column',
- 'data_source': ['sum__value'],
- 'plot_total': False,
- 'title_source': 'title',
- 'title': _('Detailed Columns'),
+ class TotalProductSales(ReportView):
- }, ]
+ report_model = MySalesItems
+ date_field = "date_placed"
+ group_by = "product"
+ columns = [
+ "title",
+ SlickReportField.create(Sum, "quantity"),
+ SlickReportField.create(Sum, "value", name="sum__value"),
+ ]
+
+ chart_settings = [
+ {
+ "type": "column",
+ "data_source": ["sum__value"],
+ "plot_total": False,
+ "title_source": "title",
+ "title": _("Detailed Columns"),
+ },
+ ]
To get something like this
@@ -92,19 +96,22 @@ You can do a monthly time series :
.. code-block:: python
# in views.py
- from slick_reporting.views import SlickReportView
+ from slick_reporting.views import ReportView
from slick_reporting.fields import SlickReportField
from .models import MySalesItems
- class MonthlyProductSales(SlickReportView):
+
+ class MonthlyProductSales(ReportView):
report_model = MySalesItems
- date_field = 'date_placed'
- group_by = 'product'
- columns = ['name', 'sku']
+ date_field = "date_placed"
+ group_by = "product"
+ columns = ["name", "sku"]
# Analogy for time series
- time_series_pattern = 'monthly'
- time_series_columns = [SlickReportField.create(Sum, 'quantity', name='sum__quantity') ]
+ time_series_pattern = "monthly"
+ time_series_columns = [
+ SlickReportField.create(Sum, "quantity", name="sum__quantity")
+ ]
This would return a table looking something like this:
@@ -127,18 +134,17 @@ This would return a table looking something like this:
**On a low level**
-You can interact with the `ReportGenerator` using same syntax as used with the `SlickReportView` .
+You can interact with the `ReportGenerator` using same syntax as used with the `ReportView` .
.. code-block:: python
from slick_reporting.generator import ReportGenerator
- from . models import MySalesModel
+ from .models import MySalesModel
- report = ReportGenerator(report_model=MySalesModel,
- group_by='product',
- columns=['title', '__total__']
+ report = ReportGenerator(
+ report_model=MySalesModel, group_by="product", columns=["title", "__total__"]
)
- report.get_report_data() #-> [{'title':'Product 1', '__total__: 56}, {'title':'Product 2', '__total__: 43}, ]
+ report.get_report_data() # -> [{'title':'Product 1', '__total__: 56}, {'title':'Product 2', '__total__: 43}, ]
This is just a scratch, for more please visit the documentation
diff --git a/docs/source/_static/crosstab.png b/docs/source/_static/crosstab.png
new file mode 100644
index 0000000..44e098e
Binary files /dev/null and b/docs/source/_static/crosstab.png differ
diff --git a/docs/source/_static/group_report.png b/docs/source/_static/group_report.png
new file mode 100644
index 0000000..3293a04
Binary files /dev/null and b/docs/source/_static/group_report.png differ
diff --git a/docs/source/_static/list_view_form.png b/docs/source/_static/list_view_form.png
new file mode 100644
index 0000000..de97121
Binary files /dev/null and b/docs/source/_static/list_view_form.png differ
diff --git a/docs/source/charts.rst b/docs/source/charts.rst
new file mode 100644
index 0000000..eff6874
--- /dev/null
+++ b/docs/source/charts.rst
@@ -0,0 +1,17 @@
+Charting
+---------
+
+Charts settings is a list of objects which each object represent a chart configurations.
+
+* type: what kind of chart it is: Possible options are bar, pie, line and others subject of the underlying charting engine.
+ Hats off to : `Charts.js `_.
+* engine_name: String, default to ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``. Passed to front end in order to use the appropriate chart engine.
+ By default supports `highcharts` & `chartsjs`.
+* data_source: Field name containing the numbers we want to plot.
+* title_source: Field name containing labels of the data_source
+* title: the Chart title. Defaults to the `report_title`.
+* plot_total if True the chart will plot the total of the columns. Useful with time series and crosstab reports.
+
+On front end, for each chart needed we pass the whole response to the relevant chart helper function and it handles the rest.
+
+
diff --git a/docs/source/concept.rst b/docs/source/concept.rst
index 0d8ccb3..ac80015 100644
--- a/docs/source/concept.rst
+++ b/docs/source/concept.rst
@@ -9,18 +9,35 @@ And now, Let's explore the main components of Django Slick Reporting and what se
Components
----------
+These are the main components of Django Slick Reporting, ordered from low level to high level:
+1. Report Field: represent a calculation unit, for example: a Sum or a Count of a certain field.
+ The report field identifies how the calculation should be done. ReportFields can depend on each other.
-1. Report Field: represent a number, a calculation unit, for example: a Sum of a certain field.
- The report field identifies how the calculation should be done. ResultFields can depend on each other.
+2. Generator: The heart of the reporting engine , It's responsible for computing and generating the data and provides low level access.
-2. Generator: Represent a concrete report structure.If it would group by certain field, do a time series or a cross tab, and which columns (Report Field) to be calculated.
- It's also responsible for computing and provides low level access. *ie you can get the data in a list of dict/objects*
+3. View: A wrapper around the generator exposing the generator options in a FormView that you can hook straight to your urls.
+ It also provide a Search Form to filter the report on.
+ It mimics the Generator API interface, so knowing one is enough to work with the other.
-3. View: Responsible for creating a nice form to filter the report on, pass those filters to the generator class to create the report.
- It mimic the Generator API. Provide high level access. *You can hook it to your urls.py and you're all set, with the charts.*
+4. Charting JS helpers: Django slick Reporting comes with highcharts and Charts js helpers libraries to plot the data generated.
+
+
+Types of Reports
+----------------
+
+1. Time Series: A report that is grouped by a date field, and the report fields are calculated on each group.
+ For example: Sum of sales per month, Count of sales per day, etc..
+
+2. Cross Tab: shows data in rows and columns with information summarized at the intersection points.
+ For example: Sum of product sales per month, crosstab by client would show Products as rows, clients included in the crosstab_ids as columns.
+
+3. Grouped: A report that is grouped by a field, and the report fields are calculated on each group.
+ For example: Sum of sales per product, Count of sales per product, etc..
+
+4. Flat: A report that is not grouped, similar to what an admin list view would show.
+ For example: Sales Transactions log
-4. Charting JS helpers: Django slick Reporting comes with highcharts and Charts js helpers libraries to plot the charts generated by the View
Settings
@@ -34,19 +51,19 @@ Settings
.. code-block:: python
SLICK_REPORTING_FORM_MEDIA = {
- 'css': {
- 'all': (
- 'https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.css',
- 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css',
- )
- },
- 'js': (
- 'https://code.jquery.com/jquery-3.3.1.slim.min.js',
- 'https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.js',
- 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js',
- 'https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js',
- 'https://code.highcharts.com/highcharts.js',
- )
+ "css": {
+ "all": (
+ "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.css",
+ "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css",
+ )
+ },
+ "js": (
+ "https://code.jquery.com/jquery-3.3.1.slim.min.js",
+ "https://cdn.datatables.net/v/bs4/dt-1.10.20/datatables.min.js",
+ "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js",
+ "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js",
+ "https://code.highcharts.com/highcharts.js",
+ ),
}
4. ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``: Controls the default chart engine used.
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 305a80b..eabe897 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -14,20 +14,20 @@
import sys
import django
-sys.path.insert(0, os.path.abspath('../../'))
-os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings'
+sys.path.insert(0, os.path.abspath("../../"))
+os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
django.setup()
# -- Project information -----------------------------------------------------
-project = 'Django Slick Reporting'
-copyright = '2020, Ramez Ashraf'
-author = 'Ramez Ashraf'
+project = "Django Slick Reporting"
+copyright = "2020, Ramez Ashraf"
+author = "Ramez Ashraf"
-master_doc = 'index'
+master_doc = "index"
# The full version, including alpha/beta/rc tags
-release = '0.6.8'
+release = "0.6.8"
# -- General configuration ---------------------------------------------------
@@ -37,12 +37,13 @@
autosummary_generate = True
autoclass_content = "class"
extensions = [
- 'sphinx.ext.viewcode', 'sphinx.ext.autodoc',
- 'sphinx.ext.autosummary',
+ "sphinx.ext.viewcode",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.autosummary",
]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@@ -54,9 +55,9 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
-html_theme = 'sphinx_rtd_theme'
+html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
diff --git a/docs/source/crosstab_options.rst b/docs/source/crosstab_options.rst
new file mode 100644
index 0000000..1cbaaa4
--- /dev/null
+++ b/docs/source/crosstab_options.rst
@@ -0,0 +1,101 @@
+Crosstab Reports
+=================
+Use crosstab reports, also known as matrix reports, to show the relationships between three or more query items.
+Crosstab reports show data in rows and columns with information summarized at the intersection points.
+
+Here is a simple example of a crosstab report:
+
+.. code-block:: python
+
+ from django.utils.translation import gettext_lazy as _
+ from django.db.models import Sum
+ from slick_reporting.views import ReportView
+
+
+ class MyCrosstabReport(ReportView):
+
+ crosstab_field = "client"
+ # the column you want to make a crosstab on, can be a foreign key or a choice field
+
+ crosstab_columns = [
+ SlickReportField.create(Sum, "value", verbose_name=_("Value")),
+ ]
+
+ crosstab_ids = None
+ # the ids of the crosstab field you want to use. This will be passed on by the search form, or , if set here, values here will be used.
+ # crosstab_ids = [1,2,3]
+ # OR in case of a choice / text field
+ # crosstab_ids = ["my-choice-1", "my-choice-2", "my-choice-3"]
+
+ crosstab_compute_remainder = True
+ # Compute reminder will add a column with the remainder of the crosstab computed
+ # Example: if you choose to do a cross tab on clientIds 1 & 2 , cross tab remainder will add a column with the calculation of all clients except those set/passed in crosstab_ids
+
+ columns = [
+ "some_optional_field",
+ "__crosstab__",
+ # You can customize where the crosstab columns are displayed in relation to the other columns
+ SlickReportField.create(Sum, "value", verbose_name=_("Total Value")),
+ # This is the same as the Same as the calculation in the crosstab, but this one will be on the whole set. IE total value
+ ]
+
+Customizing the verbose name of the crosstab columns
+----------------------------------------------------
+You can customize the verbose name of the crosstab columns by Customizing the ``ReportField`` and setting the ``crosstab_field_verbose_name`` attribute to your custom class.
+Default is that the verbose name will display the id of the crosstab field, and the remainder column will be called "The remainder".
+
+
+.. code-block:: python
+
+ class CustomCrossTabTotalField(SlickReportField):
+
+ calculation_field = "value"
+ calculation_method = Sum
+ verbose_name = _("Total Value")
+
+ @classmethod
+ def get_crosstab_field_verbose_name(cls, model, id):
+ from .models import Client
+
+ if id == "----": # the remainder column
+ return _("Rest of clients")
+ name = Client.objects.get(pk=id).name
+ # OR if you crosstab on a choice field
+ # name = get_choice_name(model, "client", id)
+ return f"{cls.verbose_name} {name}"
+
+
+Example
+-------
+
+.. code-block:: python
+
+ from .models import MySales
+
+
+ class MyCrosstabReport(ReportView):
+
+ date_field = "date"
+ group_by = "product"
+ report_model = MySales
+ crosstab_field = "client"
+
+ crosstab_columns = [
+ SlickReportField.create(Sum, "value", verbose_name=_("Value")),
+ ]
+
+ crosstab_ids = [1, 2] # either set here via the filter form
+ crosstab_compute_remainder = True
+
+
+The above code would return a result like this:
+
+.. image:: _static/crosstab.png
+ :width: 800
+ :alt: crosstab
+ :align: center
+
+
+1. The Group By. In this example, it is the product field.
+2. The Crosstab. In this example, it is the client field. crosstab_ids were set to client 1 and client 2
+3. The remainder. In this example, it is the rest of the clients. crosstab_compute_remainder was set to True
\ No newline at end of file
diff --git a/docs/source/group_by_report.rst b/docs/source/group_by_report.rst
new file mode 100644
index 0000000..dd1556e
--- /dev/null
+++ b/docs/source/group_by_report.rst
@@ -0,0 +1,51 @@
+================
+Group By Reports
+================
+
+Group by reports are reports that group the data by a specific field. For example, a report that groups the expenses by the expense type.
+
+Example:
+
+.. code-block:: python
+
+ class ExpenseTotal(ReportView):
+ report_model = ExpenseTransaction
+ report_title = _("Expenses Daily")
+ group_by = "expense"
+
+ columns = [
+ "name", # name field on the expense model
+ SlickReportField.create(Sum, "value", verbose_name=_("Total Expenditure"), name="value"),
+ ]
+
+
+Group by can also be a traversing field
+
+
+.. note::
+ If the group by field is a traversing field, the report will be grouped by the last field in the traversing path.
+ and the columns available will be the fields on the last model in the traversing path.
+
+
+Example:
+
+.. code-block:: python
+
+ class ExpenseTotal(ReportView):
+ report_model = ExpenseTransaction
+ report_title = _("Expenses Daily")
+ group_by = "expense__expensecategory" # Note the traversing
+
+ columns = [
+ "name", # name field on the ExpenseCategory model
+ SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"),
+ ]
+
+A Sample group by report would look like this:
+
+.. image:: _static/group_report.png
+ :width: 800
+ :alt: Group Report
+ :align: center
+
+
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 97566c2..353d868 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -31,39 +31,43 @@ Options
Quickstart
----------
-You can start by using ``SlickReportView`` which is a subclass of ``django.views.generic.FormView``
+You can start by using ``ReportView`` which is a subclass of ``django.views.generic.FormView``
.. code-block:: python
# in views.py
- from slick_reporting.views import SlickReportView
+ from slick_reporting.views import ReportView
from slick_reporting.fields import SlickReportField
from .models import MySalesItems
- class MonthlyProductSales(SlickReportView):
+
+ class MonthlyProductSales(ReportView):
# The model where you have the data
report_model = MySalesItems
# the main date field used for the model.
- date_field = 'date_placed' # or 'order__date_placed'
+ date_field = "date_placed" # or 'order__date_placed'
# this support traversing, like so
# date_field = 'order__date_placed'
# A foreign key to group calculation on
- group_by = 'product'
+ group_by = "product"
# The columns you want to display
- columns = ['title',
- SlickReportField.create(method=Sum, field='value', name='value__sum', verbose_name=_('Total sold $'))
- ]
+ columns = [
+ "title",
+ SlickReportField.create(
+ method=Sum, field="value", name="value__sum", verbose_name=_("Total sold $")
+ ),
+ ]
# Charts
charts_settings = [
- {
- 'type': 'bar',
- 'data_source': 'value__sum',
- 'title_source': 'title',
- },
+ {
+ "type": "bar",
+ "data_source": "value__sum",
+ "title_source": "title",
+ },
]
@@ -76,13 +80,16 @@ Next step :ref:`structure`
concept
the_view
view_options
+ group_by_report
time_series_options
+ crosstab_options
+ list_report_options
+ search_form
+ charts
report_generator
computation_field
-
-
Indices and tables
==================
diff --git a/docs/source/list_report_options.rst b/docs/source/list_report_options.rst
new file mode 100644
index 0000000..152ed03
--- /dev/null
+++ b/docs/source/list_report_options.rst
@@ -0,0 +1,28 @@
+List Report View
+================
+
+This is a simple ListView to display data in a model, like you would with an admin ChangeList view.
+It's quite similar to ReportView except there is no calculation, by default.
+
+Options:
+--------
+
+filters: a list of report_model fields to be used as filters.
+
+.. code-block:: python
+
+ class RequestLog(ListReportView):
+ report_model = Request
+
+ filters = ["method", "path", "user_agent", "user", "referer", "response"]
+
+
+Would yield a form like this
+
+.. image:: _static/list_view_form.png
+ :width: 800
+ :alt: ListReportView form
+ :align: center
+
+
+Check :ref:`filter_form_customization` To customize the form as you wish.
diff --git a/docs/source/report_generator.rst b/docs/source/report_generator.rst
index e752d3b..dca83f1 100644
--- a/docs/source/report_generator.rst
+++ b/docs/source/report_generator.rst
@@ -13,6 +13,7 @@ ReportGenerator
.. rubric:: Below are the basic needed attrs
.. autoattribute:: report_model
+ .. autoattribute:: queryset
.. autoattribute:: date_field
.. autoattribute:: columns
.. autoattribute:: group_by
@@ -24,10 +25,10 @@ ReportGenerator
.. automethod:: get_time_series_field_verbose_name
.. rubric:: Below are the needed attrs and methods for crosstab manipulation
- .. autoattribute:: crosstab_model
+ .. autoattribute:: crosstab_field
.. autoattribute:: crosstab_columns
.. autoattribute:: crosstab_ids
- .. autoattribute:: crosstab_compute_reminder
+ .. autoattribute:: crosstab_compute_remainder
.. automethod:: get_crosstab_field_verbose_name
.. rubric:: Below are the magical attrs
diff --git a/docs/source/search_form.rst b/docs/source/search_form.rst
new file mode 100644
index 0000000..3ce76dc
--- /dev/null
+++ b/docs/source/search_form.rst
@@ -0,0 +1,128 @@
+.. _filter_form:
+
+Filter Form
+===========
+
+The filter form is a form that is used to filter the data to be used in the report.
+
+
+How the search form is generated ?
+-----------------------------------
+Behind the scene, The view calls ``slick_reporting.form_factory.report_form_factory`` in ``get_form_class`` method.
+``report_form_factory`` is a helper method which generates a form containing start date and end date, as well as all foreign keys on the report_model.
+
+
+You can customize the generated form:
+
+# Todo
+
+You can also override the form by providing a ``form_class`` attribute to the report view.
+
+
+.. _filter_form_customization:
+
+Override the Form
+------------------
+
+The system expect that the form used with the ``ReportView`` to implement the ``slick_reporting.forms.BaseReportForm`` interface.
+
+The interface is simple, only 3 mandatory methods to implement, The rest are mandatory only if you are working with a crosstab report or a time series report.
+
+
+* ``get_filters``: Mandatory, return a tuple (Q_filers , kwargs filter) to be used in filtering.
+ q_filter: can be none or a series of Django's Q queries
+ kwargs_filter: None or a dictionary of filters
+
+* ``get_start_date``: Mandatory, return the start date of the report.
+
+* ``get_end_date``: Mandatory, return the end date of the report.
+
+* ``get_crispy_helper`` : return a crispy form helper to be used in rendering the form. (optional)
+
+In case you are working with a crosstab report, you need to implement the following methods:
+
+* ``get_crosstab_compute_remainder``: return a boolean indicating if the remainder should be computed or not.
+
+* ``get_crosstab_ids``: return a list of ids to be used in the crosstab report.
+
+
+And in case you are working with a time series report, with a selector on, you need to implement the following method:
+
+* ``get_time_series_pattern``: return a string representing the time series pattern. ie: ``ie: daily, monthly, yearly``
+
+Example a full example of a custom form:
+
+.. code-block:: python
+
+ # forms.py
+ from slick_reporting.forms import BaseReportForm
+
+ # A Normal form , Inheriting from BaseReportForm
+ class RequestLogForm(BaseReportForm, forms.Form):
+
+ SECURE_CHOICES = (
+ ("all", "All"),
+ ("secure", "Secure"),
+ ("non-secure", "Not Secure"),
+ )
+
+ start_date = forms.DateField(
+ required=False,
+ label="Start Date",
+ widget=forms.DateInput(attrs={"type": "date"}),
+ )
+ end_date = forms.DateField(
+ required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"})
+ )
+ secure = forms.ChoiceField(
+ choices=SECURE_CHOICES, required=False, label="Secure", initial="all"
+ )
+ method = forms.CharField(required=False, label="Method")
+ response = forms.ChoiceField(
+ choices=HTTP_STATUS_CODES,
+ required=False,
+ label="Response",
+ initial="200",
+ )
+ other_people_only = forms.BooleanField(
+ required=False, label="Show requests from other People Only"
+ )
+
+ def __init__(self, request=None, *args, **kwargs):
+ self.request = request
+ super(RequestLogForm, self).__init__(*args, **kwargs)
+ # provide initial values and ay needed customization
+ self.fields["start_date"].initial = datetime.date.today()
+ self.fields["end_date"].initial = datetime.date.today()
+
+ def get_filters(self):
+ # return the filters to be used in the report
+ # Note: the use of Q filters and kwargs filters
+ filters = {}
+ q_filters = []
+ if self.cleaned_data["secure"] == "secure":
+ filters["is_secure"] = True
+ elif self.cleaned_data["secure"] == "non-secure":
+ filters["is_secure"] = False
+ if self.cleaned_data["method"]:
+ filters["method"] = self.cleaned_data["method"]
+ if self.cleaned_data["response"]:
+ filters["response"] = self.cleaned_data["response"]
+ if self.cleaned_data["other_people_only"]:
+ q_filters.append(~Q(user=self.request.user))
+
+ return q_filters, filters
+
+ def get_start_date(self):
+ return self.cleaned_data["start_date"]
+
+ def get_end_date(self):
+ return self.cleaned_data["end_date"]
+
+ # ----
+ # in reports.py
+ @register_report_view
+ class RequestCountByPath(ReportView):
+ form_class = RequestLogForm
+
+You can view this code snippet in action on the demo project
diff --git a/docs/source/the_view.rst b/docs/source/the_view.rst
index 76e8695..453f640 100644
--- a/docs/source/the_view.rst
+++ b/docs/source/the_view.rst
@@ -1,24 +1,18 @@
.. _customization:
-The Slick Report View and form
-===============================
+The Slick Report View
+=====================
-What is SlickReportView?
------------------------
+What is ReportView?
+--------------------
-SlickReportView is a CBV that inherits form ``FromView`` and expose the report generator needed attributes.
-Also
+ReportView is a CBV that inherits form ``FromView`` and expose the report generator needed attributes.
+It:
-* Auto generate the search form
-* return the results as a json response if ajax request
-* Works on GET and POST
+* Auto generate the search form based on the report model (Or you can create you own)
+* return the results as a json response if it's ajax request.
* Export to CSV (extendable to apply other exporting method)
-
-
-How the search form is generated ?
------------------------------------
-Behind the scene, Sample report calls ``slick_reporting.form_factory.report_form_factory``
-a helper method which generates a form containing start date and end date, as well as all foreign keys on the report_model.
+* Print the report in a dedicated format
Export to CSV
@@ -30,46 +24,10 @@ You can extend the functionality, say you want to export to pdf.
Add a ``export_pdf`` method to the view class, accepting the report_data json response and return the response you want.
This ``export_pdf` will be called automatically when url parameter contain ``?_export=pdf``
-Having an `_export` parameter not implemented, to say the view class do not implement ``export_{parameter_name}``, will be ignored.
-
-SlickReportingListView
------------------------
-This is a simple ListView to display data in a model, like you would with an admin ChangeList view.
-It's a simple ListView with a few extra features:
-
-filters: a list of report_model fields to be used as filters.
-
-
-
-Override the Form
-------------------
-
-The Form purposes are
-
-1. Provide the start_date and end_date
-2. provide a ``get_filters`` method which return a tuple (Q_filers , kwargs filter) to be used in filtering.
- q_filter: can be none or a series of Django's Q queries
- kwargs_filter: None or a dict of filters
+Having an `_export` parameter not implemented, ie the view class do not implement ``export_{parameter_name}``, will be ignored.
-Following those 2 recommendation, your awesome custom form will work as you'd expect.
-Charting
----------
-
-Charts settings is a list of objects which each object represent a chart configurations.
-
-* type: what kind of chart it is: Possible options are bar, pie, line and others subject of the underlying charting engine.
- Hats off to : `Charts.js `_.
-* engine_name: String, default to ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``. Passed to front end in order to use the appropriate chart engine.
- By default supports `highcharts` & `chartsjs`.
-* data_source: Field name containing the numbers we want to plot.
-* title_source: Field name containing labels of the data_source
-* title: the Chart title. Defaults to the `report_title`.
-* plot_total if True the chart will plot the total of the columns. Useful with time series and crosstab reports.
-
-On front end, for each chart needed we pass the whole response to the relevant chart helper function and it handles the rest.
-
The ajax response structure
---------------------------
@@ -87,63 +45,81 @@ Let's have a look
# a list of objects representing the actual results of the report
"data": [
- {"name": "Product 0", "quantity__sum": "1774", "value__sum": "8758", "field_n" : "value_n"},
- # .....
+ {
+ "name": "Product 1",
+ "quantity__sum": "1774",
+ "value__sum": "8758",
+ "field_x": "value_x",
+ },
+ {
+ "name": "Product 2",
+ "quantity__sum": "1878",
+ "value__sum": "3000",
+ "field_x": "value_x",
+ },
+ # etc .....
],
# A list explaining the columns/keys in the data results.
# ie: len(response.columns) == len(response.data[i].keys())
- # Contains needed information about the verbose name , if summable , hints about the data type.
+ # It contains needed information about verbose name , if summable and hints about the data type.
"columns": [
- {"name": "name",
- "computation_field": "",
- "verbose_name": "Name",
- "visible": True,
- "type": "CharField",
- "is_summable": False
- },
- {"name": "quantity__sum",
- "computation_field": "",
- "verbose_name": "Quantities Sold",
- "visible": True,
- "type": "number",
- "is_summable": True},
- {"name": "value__sum",
- "computation_field": "",
- "verbose_name": "Value $",
- "visible": True,
- "type": "number",
- "is_summable": True}
+ {
+ "name": "name",
+ "computation_field": "",
+ "verbose_name": "Name",
+ "visible": True,
+ "type": "CharField",
+ "is_summable": False,
+ },
+ {
+ "name": "quantity__sum",
+ "computation_field": "",
+ "verbose_name": "Quantities Sold",
+ "visible": True,
+ "type": "number",
+ "is_summable": True,
+ },
+ {
+ "name": "value__sum",
+ "computation_field": "",
+ "verbose_name": "Value $",
+ "visible": True,
+ "type": "number",
+ "is_summable": True,
+ },
],
-
# Contains information about the report as whole if it's time series or a a crosstab
# And what's the actual and verbose names of the time series or crosstab specific columns.
- "metadata": {"time_series_pattern": "",
- "time_series_column_names": [],
- "time_series_column_verbose_names": [],
- "crosstab_model": '',
- "crosstab_column_names": [],
- "crosstab_column_verbose_names": []
- },
-
-
- # a mirror of the set charts_settings on the SlickReportView
- # SlickReportView populates the id if missing and fill the `engine_name' if not set
+ "metadata": {
+ "time_series_pattern": "",
+ "time_series_column_names": [],
+ "time_series_column_verbose_names": [],
+ "crosstab_model": "",
+ "crosstab_column_names": [],
+ "crosstab_column_verbose_names": [],
+ },
+
+ # A mirror of the set charts_settings on the ReportView
+ # ``ReportView`` populates the id and the `engine_name' if not set
"chart_settings": [
- {"type": "pie",
- 'engine_name': 'highcharts',
- "data_source": ["quantity__sum"],
- "title_source": ["name"],
- "title": "Pie Chart (Quantities)",
- "id": "pie-0"},
-
- {"type": "bar",
- "engine_name": "chartsjs",
- "data_source": ["value__sum"],
- "title_source": ["name"],
- "title": "Column Chart (Values)",
- "id": "bar-1"}
- ]
+ {
+ "type": "pie",
+ "engine_name": "highcharts",
+ "data_source": ["quantity__sum"],
+ "title_source": ["name"],
+ "title": "Pie Chart (Quantities)",
+ "id": "pie-0",
+ },
+ {
+ "type": "bar",
+ "engine_name": "chartsjs",
+ "data_source": ["value__sum"],
+ "title_source": ["name"],
+ "title": "Column Chart (Values)",
+ "id": "bar-1",
+ },
+ ],
}
diff --git a/docs/source/time_series_options.rst b/docs/source/time_series_options.rst
index d56aaea..bb07467 100644
--- a/docs/source/time_series_options.rst
+++ b/docs/source/time_series_options.rst
@@ -1,6 +1,7 @@
Time Series Reports
==================
-
+A Time series report is a report that is generated for a periods of time.
+The period can be daily, weekly, monthly, yearly or custom, calculations will be performed for each period in the time series.
Here is a quick recipe to what you want to do
@@ -8,9 +9,9 @@ Here is a quick recipe to what you want to do
from django.utils.translation import gettext_lazy as _
from django.db.models import Sum
- from slick_reporting.views import SlickReportView
+ from slick_reporting.views import ReportView
- class MyReport(SlickReportView):
+ class MyReport(ReportView):
time_series_pattern = "monthly"
# options are : "daily", "weekly", "monthly", "yearly", "custom"
@@ -39,22 +40,49 @@ Here is a quick recipe to what you want to do
]
-
-
-
time_series_selector = True
# This will display a selector to change the time series pattern
# settings for the time series selector
# ----------------------------------
- time_series_selector_choices=None # A list Choice tuple [(value, label), ...]
+ time_series_selector_choices = None # A list Choice tuple [(value, label), ...]
time_series_selector_default = "monthly" # The initial value for the time series selector
time_series_selector_label = _("Period Pattern) # The label for the time series selector
time_series_selector_allow_empty = False # Allow the user to select an empty time series
+Time Series Options
+-------------------
+
+.. attribute:: ReportView.time_series_pattern
+
+ the time series pattern to be used in the report, it can be one of the following:
+ Possible options are: daily, weekly, semimonthly, monthly, quarterly, semiannually, annually and custom.
+ if `custom` is set, you'd need to override `time_series_custom_dates`
+
+.. attribute:: ReportView.time_series_custom_dates
+
+ A list of tuples of (start_date, end_date) pairs indicating the start and end of each period.
+
+.. attribute:: ReportView.time_series_columns
+
+ a list of Calculation Field names which will be included in the series calculation.
+
+ .. code-block:: python
+ class MyReport(ReportView):
+
+ time_series_columns = [
+ SlickReportField.create(Sum, "value", verbose_name=_("Value"), is_summable=True, name="sum__value"),
+ SlickReportField.create(Avg, "Price", verbose_name=_("Avg Price"), is_summable=False)
+
+ ]
+
+
+
+
+
Links to demo
-------------
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index 0b89580..9cbaf47 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -44,14 +44,22 @@ A ReportView like the below
.. code-block:: python
# in your urls.py
- path('path-to-report', TransactionsReport.as_view())
+ path("path-to-report", TransactionsReport.as_view())
# in your views.py
- from slick_reporting.views import SlickReportView
+ from slick_reporting.views import ReportView
- class TransactionsReport(SlickReportView):
+
+ class TransactionsReport(ReportView):
report_model = MySalesItem
- columns = ['order_date', 'product__name' , 'client__name', 'quantity', 'price', 'value' ]
+ columns = [
+ "order_date",
+ "product__name",
+ "client__name",
+ "quantity",
+ "price",
+ "value",
+ ]
will yield a Page with a nice filter form with
@@ -86,10 +94,10 @@ which can be written like this:
.. code-block:: python
- class TotalQuanAndValueReport(SlickReportView):
+ class TotalQuanAndValueReport(ReportView):
report_model = MySalesItem
- group_by = 'product'
- columns = ['name', '__total_quantity__', '__total__' ]
+ group_by = "product"
+ columns = ["name", "__total_quantity__", "__total__"]
@@ -113,13 +121,13 @@ can be written like this
.. code-block:: python
- class TotalQuantityMonthly(SlickReportView):
+ class TotalQuantityMonthly(ReportView):
report_model = MySalesItem
- group_by = 'product'
- columns = ['name', 'sku']
+ group_by = "product"
+ columns = ["name", "sku"]
- time_series_pattern = 'monthly'
- time_series_columns = ['__total_quantity__']
+ time_series_pattern = "monthly"
+ time_series_columns = ["__total_quantity__"]
4. Cross tab report
@@ -142,15 +150,15 @@ Which can be written like this
.. code-block:: python
- class CrosstabProductClientValue(SlickReportView):
- report_model = MySalesItem
- group_by = 'product'
- columns = ['name', 'sku']
+ class CrosstabProductClientValue(ReportView):
+ report_model = MySalesItem
+ group_by = "product"
+ columns = ["name", "sku"]
- crosstab_model = 'client'
- crosstab_columns = ['__total_value__']
- crosstab_ids = [client1.pk, client2.pk, client3.pk]
- crosstab_compute_reminder = True
+ crosstab_model = "client"
+ crosstab_columns = ["__total_value__"]
+ crosstab_ids = [client1.pk, client2.pk, client3.pk]
+ crosstab_compute_remainder = True
diff --git a/docs/source/view_options.rst b/docs/source/view_options.rst
index 2fe620c..a7fd448 100644
--- a/docs/source/view_options.rst
+++ b/docs/source/view_options.rst
@@ -1,49 +1,260 @@
-
Report View Options
===================
We can categorize the output of a report into 4 sections:
-* List report: Similar to a django changelist, it's a direct view of the report model records with some extra features like sorting, filtering, pagination, etc.
-* Grouped report: similar to what you'd expect from a SQL group by query, it's a list of records grouped by a certain field
-* Time series report: a step up from the grouped report, where the results are computed for each time period (day, week, month, year, etc) or you can specify a custom periods.
-* Crosstab report: It's a report where a table showing the relationship between two or more variables. (like Client sales of each product comparison)
+#. Grouped report: similar to what you'd so with a GROUP BY sql statement. We group by a field and do some kind of calculations over the grouped records.
+#. Time series report: a step up from the previous grouped report, where the calculations are done for each time period set in the time series options.
+#. Crosstab report: It's a report where the results shows the relationship between two or more variables. Example: Rows are the clients, columns are the products, and the values are the number of sales for each client/product combination.
+#. List report: Similar to a django changelist, it's a direct view of the report model records with some extra features like sorting, filtering, pagination, etc.
+
+
+In following sections we will explore the different options for each type of report.
+Below is the general list of options that can be used to control the behavior of the report view.
+
+``ReportView`` Options
+----------------------
+
+.. attribute:: ReportView.report_model
+
+ the model where the relevant data is stored, in more complex reports, it's usually a database view / materialized view.
+
+.. attribute:: ReportView.queryset
+
+ the queryset to be used in the report, if not specified, it will default to ``report_model._default_manager.all()``
+
+
+.. attribute:: ReportView.columns
+
+ Columns can be a list of column names , or a tuple of (column name, options dictionary) pairs.
+
+ Example:
+
+ .. code-block:: python
+
+ class MyReport(ReportView):
+ columns = [
+ 'id',
+ ('name', {'verbose_name': "My verbose name", "is_summable"=False}),
+ 'description',
+
+ # A callable on the view /or the generator, that takes the record as a parameter and returns a value.
+ ('get_full_name', {"verbose_name"="Full Name", "is_summable"=False} ),
+ ]
+
+ def get_full_name(self, record):
+ return record['first_name'] + " " + record['last_name']
+
+
+ Columns names can be
+
+ * A Computation Field, as a class or by its name if its registered (see :ref:`computation_field`)
+ Example:
+
+ .. code-block:: python
+
+ class MyTotalReportField(SlickReportField):
+ pass
+
+ class MyReport(ReportView):
+ columns = [
+ SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"),
+ # a computation field created on the fly
+
+ MyTotalReportField,
+ # A computation Field class
+
+ "__total__",
+ # a computation field registered in the computation field registry
+ ]
+
+
+
+
+ * If group_by is set and it's a foreign key, then any field on the grouped by model.
+
+ Example:
+
+ .. code-block:: python
+
+ class MyReport(ReportView):
+ report_model = MySales
+ group_by = 'client'
+ columns = [
+ 'name', # field that exists on the Client Model
+ 'date_of_birth', # field that exists on the Client Model
+ "agent__name", # field that exists on the Agent Model related to the Client Model
+
+ # calculation fields
+ ]
+
+
+
+
+ * If group_by is not set, then
+ 1. Any field name on the report_model / queryset
+ 2. A calculation field, in this case the calculation will be made on the whole set of records, not on each group.
+ Example:
+
+ .. code-block:: python
+
+ class MyReport(ReportView):
+ report_model = MySales
+ group_by = None
+ columns = [
+ SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value")
+ ]
+
+ Above code will return the calculated sum of all values in the report_model / queryset
+
+ * A callable on the view /or the generator, that takes the record as a parameter and returns a value.
+
+ * A Special ``__time_series__``, and ``__crosstab__``
+
+ Those are used to control the position of the time series inside the columns, defaults it's appended at the end
+
+
+.. attribute:: ReportView.date_field
+
+ the date field to be used in filtering and computing
+
+.. attribute:: ReportView.start_date_field_name
+
+ the name of the start date field, if not specified, it will default to ``date_field``
+
+.. attribute:: ReportView.end_date_field_name
+
+ the name of the end date field, if not specified, it will default to ``date_field``
+
+
+.. attribute:: ReportView.group_by
+
+ the group by field, it can be a foreign key, a text field, on the report model or traversing a foreign key.
+
+ Example:
+
+ .. code-block:: python
+
+ class MyReport(ReportView):
+ report_model = MySalesModel
+ group_by = 'client'
+ # OR
+ # group_by = 'client__agent__name'
+ # OR
+ # group_by = 'client__agent'
+
+
+.. attribute:: ReportView.report_title
+
+ the title of the report to be displayed in the report page.
+
+.. attribute:: ReportView.report_title_context_key
+
+ the context key to be used to pass the report title to the template, default to ``title``.
+
+
+.. attribute:: ReportView.chart_settings
+
+ A list of Chart objects representing the charts you want to attach to the report.
+
+ Example:
+
+ .. code-block:: python
+
+ class MyReport(ReportView):
+ report_model = Request
+ # ..
+ chart_settings = [
+ Chart(
+ "Browsers",
+ Chart.PIE,
+ title_source=["user_agent"],
+ data_source=["count__id"],
+ plot_total=True,
+ ),
+ Chart(
+ "Browsers Bar Chart",
+ Chart.BAR,
+ title_source=["user_agent"],
+ data_source=["count__id"],
+ plot_total=True,
+ ),
+ ]
+
+
+.. attribute:: ReportView.default_order_by
+
+ Default order by for the results. Ordering can also be controlled on run time by passing order_by='field_name' as a parameter to the view.
+ As you would expect, for DESC order: default_order_by (or order_by as a parameter) ='-field_name'
+
+.. attribute:: ReportView.template_name
+
+ The template to be used to render the report, default to ``slick_reporting/simple_report.html``
+ You can override this to customize the report look and feel.
+
+.. attribute:: ReportView.limit_records
+
+ Limit the number of records to be displayed in the report, default to ``None`` (no limit)
+
+.. attribute:: ReportView.swap_sign
+
+ Swap the sign of the values in the report, default to ``False``
+
+
+.. attribute:: ReportView.csv_export_class
+
+ Set the csv export class to be used to export the report, default to ``ExportToStreamingCSV``
+
+.. attribute:: ReportView.report_generator_class
+
+ Set the generator class to be used to generate the report, default to ``ReportGenerator``
+
+.. attribute:: ReportView.with_type
+
+ Set if double sided calculations should be taken into account, default to ``False``
+ Read more about double sided calculations here https://django-erp-framework.readthedocs.io/en/latest/topics/doc_types.html
+
+.. attribute:: ReportView.doc_type_field_name
+ Set the doc_type field name to be used in double sided calculations, default to ``doc_type``
+.. attribute:: ReportView.doc_type_plus_list
-General Options
----------------
+ Set the doc_type plus list to be used in double sided calculations, default to ``None``
-* columns
+.. attribute:: ReportView.doc_type_minus_list
-Columns can be a list of column names , or a tuple of (column name, options dictionary) pairs.
+ Set the doc_type minus list to be used in double sided calculations, default to ``None``
-example:
-.. code-block:: python
- class MyReport()
- columns = [
- 'id',
- ('name', {'verbose_name': "My verbose name", is_summable=False}),
- 'description',
- ]
+Hooks and functions
+-------------------
+.. attribute:: ReportView.get_queryset()
+ Override this function to return a custom queryset to be used in the report.
-* date_field: the date field to be used in filtering and computing (ie: the time series report).
+.. attribute:: ReportView.get_report_title()
-* report_model: the model where the relevant data is stored, in more complex reports, it's usually a database view / materialized view.
+ Override this function to return a custom report title.
-* report_title: the title of the report to be displayed in the report page.
+.. attribute:: ReportView.ajax_render_to_response()
-* group_by : the group by field, if not specified, the report will be a list report.
+ Override this function to return a custom response for ajax requests.
-* excluded_fields
+.. attribute:: ReportView.format_row()
-* chart_settings : a list of dictionary (or Chart object) of charts you want to attach to the report.
+ Override this function to return a custom row format.
+.. attribute:: ReportView.filter_results(data, for_print=False)
+ Hook to Filter results, usable if you want to do actions on the data set based on computed data (like eliminate __balance__ = 0, etc)
+ :param data: the data set , list of dictionaries
+ :param for_print: if the data is being filtered for printing or not
+ :return: the data set after filtering.
+.. attribute:: ReportView.get_form_crispy_helper()
+ Override this function to return a custom crispy form helper for the report form.
diff --git a/runtests.py b/runtests.py
index 5f753e1..72dd4ee 100644
--- a/runtests.py
+++ b/runtests.py
@@ -8,22 +8,24 @@
from django.test.utils import get_runner
if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Run the Django Slick Reporting test suite.")
+ parser = argparse.ArgumentParser(
+ description="Run the Django Slick Reporting test suite."
+ )
parser.add_argument(
- 'modules', nargs='*', metavar='module',
+ "modules",
+ nargs="*",
+ metavar="module",
help='Optional path(s) to test modules; e.g. "i18n" or '
- '"i18n.tests.TranslationTests.test_lazy_objects".',
+ '"i18n.tests.TranslationTests.test_lazy_objects".',
)
options = parser.parse_args()
options.modules = [os.path.normpath(labels) for labels in options.modules]
-
- os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings'
+ os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
django.setup()
TestRunner = get_runner(settings)
test_runner = TestRunner()
failures = test_runner.run_tests(options.modules)
# failures = test_runner.run_tests(["tests"])
sys.exit(bool(failures))
-
diff --git a/setup.py b/setup.py
index fc1f76c..6068493 100644
--- a/setup.py
+++ b/setup.py
@@ -1,3 +1,3 @@
from setuptools import setup
-setup()
\ No newline at end of file
+setup()
diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py
index 01bb61f..e755575 100644
--- a/slick_reporting/__init__.py
+++ b/slick_reporting/__init__.py
@@ -1,5 +1,5 @@
default_app_config = "slick_reporting.apps.ReportAppConfig"
-VERSION = (0, 8, 0)
+VERSION = (0, 9, 0)
-__version__ = "0.8.0"
+__version__ = "0.9.0"
diff --git a/slick_reporting/app_settings.py b/slick_reporting/app_settings.py
index d2c7f66..58c2e04 100644
--- a/slick_reporting/app_settings.py
+++ b/slick_reporting/app_settings.py
@@ -1,10 +1,8 @@
-import pytz
from django.conf import settings
from django.utils.functional import lazy
import datetime
-from django.utils.timezone import now
def get_first_of_this_year():
diff --git a/slick_reporting/apps.py b/slick_reporting/apps.py
index 3c83a53..3e903b4 100644
--- a/slick_reporting/apps.py
+++ b/slick_reporting/apps.py
@@ -7,5 +7,4 @@ class ReportAppConfig(apps.AppConfig):
def ready(self):
super().ready()
-
- from . import fields
+ from . import fields # noqa
diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py
index 7896a1f..c7aba3c 100644
--- a/slick_reporting/fields.py
+++ b/slick_reporting/fields.py
@@ -1,5 +1,3 @@
-import uuid
-
from django.db.models import Sum
from django.template.defaultfilters import date as date_filter
from django.utils.translation import gettext_lazy as _
@@ -39,6 +37,11 @@ class SlickReportField(object):
"""Indicate if this computation can be summed over. Useful to be passed to frontend or whenever needed"""
report_model = None
+ """The model on which the computation would occur"""
+
+ queryset = None
+ """The queryset on which the computation would occur"""
+
group_by = None
plus_side_q = None
minus_side_q = None
@@ -75,7 +78,6 @@ def create(cls, method, field, name=None, verbose_name=None, is_summable=True):
:return:
"""
if not name:
- identifier = str(uuid.uuid4()).split("-")[-1]
name = name or f"{method.name.lower()}__{field}"
assert name not in cls._field_registry.get_all_report_fields_names()
@@ -98,7 +100,7 @@ def __init__(
plus_side_q=None,
minus_side_q=None,
report_model=None,
- qs=None,
+ queryset=None,
calculation_field=None,
calculation_method=None,
date_field="",
@@ -107,6 +109,13 @@ def __init__(
super(SlickReportField, self).__init__()
self.date_field = date_field
self.report_model = self.report_model or report_model
+ self.queryset = self.queryset or queryset
+ self.queryset = (
+ self.report_model._default_manager.all()
+ if self.queryset is None
+ else self.queryset
+ )
+
self.calculation_field = (
calculation_field if calculation_field else self.calculation_field
)
@@ -201,7 +210,7 @@ def prepare(self, q_filters=None, kwargs_filters=None, **kwargs):
return debit_results, credit_results
def get_queryset(self):
- queryset = self.report_model.objects
+ queryset = self.queryset
if self.base_q_filters:
queryset = queryset.filter(*self.base_q_filters)
if self.base_kwargs_filters:
diff --git a/slick_reporting/form_factory.py b/slick_reporting/form_factory.py
index d4552f8..2de6d41 100644
--- a/slick_reporting/form_factory.py
+++ b/slick_reporting/form_factory.py
@@ -1,242 +1,10 @@
-from collections import OrderedDict
+import warnings
-from django import forms
-from django.utils.functional import cached_property
-from django.utils.translation import gettext_lazy as _
-
-from . import app_settings
-from .helpers import get_foreign_keys
-
-TIME_SERIES_CHOICES = (
- ("monthly", _("Monthly")),
- ("weekly", _("Weekly")),
- ("annually", _("Yearly")),
- ("daily", _("Daily")),
+# warn deprecated
+warnings.warn(
+ "slick_reporting.form_factory is deprecated. Use slick_reporting.forms instead",
+ Warning,
+ stacklevel=2,
)
-
-def default_formfield_callback(f, **kwargs):
- kwargs["required"] = False
- kwargs["help_text"] = ""
- return f.formfield(**kwargs)
-
-
-def get_crispy_helper(
- foreign_keys_map=None,
- crosstab_model=None,
- crosstab_key_name=None,
- crosstab_display_compute_remainder=False,
- add_date_range=True,
-):
- from crispy_forms.helper import FormHelper
- from crispy_forms.layout import Column, Layout, Div, Row, Field
-
- helper = FormHelper()
- helper.form_class = "form-horizontal"
- helper.label_class = "col-sm-2 col-md-2 col-lg-2"
- helper.field_class = "col-sm-10 col-md-10 col-lg-10"
- helper.form_tag = False
- helper.disable_csrf = True
- helper.render_unmentioned_fields = True
-
- helper.layout = Layout()
- if add_date_range:
- helper.layout.fields.append(
- Row(
- Column(Field("start_date"), css_class="col-sm-6"),
- Column(Field("end_date"), css_class="col-sm-6"),
- css_class="raReportDateRange",
- ),
- )
- filters_container = Div(css_class="mt-20", style="margin-top:20px")
- # first add the crosstab model and its display reimder then the rest of the fields
- if crosstab_model:
- filters_container.append(Field(crosstab_key_name))
- if crosstab_display_compute_remainder:
- filters_container.append(Field("crosstab_compute_remainder"))
-
- for k in foreign_keys_map:
- if k != crosstab_key_name:
- filters_container.append(Field(k))
- helper.layout.fields.append(filters_container)
-
- return helper
-
-
-class BaseReportForm:
- """
- Holds basic function
- """
-
- @property
- def media(self):
- from .app_settings import SLICK_REPORTING_FORM_MEDIA
-
- return forms.Media(
- css=SLICK_REPORTING_FORM_MEDIA.get("css", {}),
- js=SLICK_REPORTING_FORM_MEDIA.get("js", []),
- )
-
- def get_filters(self):
- """
- Get the foreign key filters for report queryset, excluding crosstab ids, handled by `get_crosstab_ids()`
- :return: a dicttionary of filters to be used with QuerySet.filter(**returned_value)
- """
- _values = {}
- if self.is_valid():
- fk_keys = getattr(self, "foreign_keys", [])
- if fk_keys:
- fk_keys = fk_keys.items()
- for key, field in fk_keys:
- if key in self.cleaned_data and not key == self.crosstab_key_name:
- val = self.cleaned_data[key]
- if val:
- val = [x for x in val.values_list("pk", flat=True)]
- _values["%s__in" % key] = val
- return None, _values
-
- @cached_property
- def crosstab_key_name(self):
- # todo get the model more accurately
- """
- return the actual foreignkey field name by simply adding an '_id' at the end.
- This is hook is to customize this naieve approach.
- :return: key: a string that should be in self.cleaned_data
- """
- return f"{self.crosstab_model}_id"
-
- def get_crosstab_ids(self):
- """
- Get the crosstab ids so they can be sent to the report generator.
- :return:
- """
- if self.crosstab_model:
- qs = self.cleaned_data.get(self.crosstab_key_name)
- return [x for x in qs.values_list("pk", flat=True)]
- return []
-
- def get_crosstab_compute_remainder(self):
- return self.cleaned_data.get("crosstab_compute_remainder", True)
-
- def get_crispy_helper(self, foreign_keys_map=None, crosstab_model=None, **kwargs):
- return get_crispy_helper(
- self.foreign_keys,
- crosstab_model=getattr(self, "crosstab_model", None),
- crosstab_key_name=getattr(self, "crosstab_key_name", None),
- crosstab_display_compute_remainder=getattr(
- self, "crosstab_display_compute_remainder", False
- ),
- **kwargs,
- )
-
-
-def _default_foreign_key_widget(f_field):
- return {
- "form_class": forms.ModelMultipleChoiceField,
- "required": False,
- }
-
-
-def report_form_factory(
- model,
- crosstab_model=None,
- display_compute_remainder=True,
- fkeys_filter_func=None,
- foreign_key_widget_func=None,
- excluded_fields=None,
- initial=None,
- required=None,
- show_time_series_selector=False,
- time_series_selector_choices=None,
- time_series_selector_default="",
- time_series_selector_label=None,
- time_series_selector_allow_empty=False,
-):
- """
- Create a Report Form based on the report_model passed by
- 1. adding a start_date and end_date fields
- 2. extract all ForeignKeys on the report_model
-
- :param model: the report_model
- :param crosstab_model: crosstab model if any
- :param display_compute_remainder: relevant only if crosstab_model is specified. Control if we show the check to
- display the rest.
- :param fkeys_filter_func: a receives an OrderedDict of Foreign Keys names and their model field instances found on the model, return the OrderedDict that would be used
- :param foreign_key_widget_func: receives a Field class return the used widget like this {'form_class': forms.ModelMultipleChoiceField, 'required': False, }
- :param excluded_fields: a list of fields to be excluded from the report form
- :param initial a dict for fields initial
- :param required a list of fields that should be marked as required
- :return:
- """
- foreign_key_widget_func = foreign_key_widget_func or _default_foreign_key_widget
- fkeys_filter_func = fkeys_filter_func or (lambda x: x)
-
- # gather foreign keys
- initial = initial or {}
- required = required or []
- fkeys_map = get_foreign_keys(model)
- excluded_fields = excluded_fields or []
- for excluded in excluded_fields:
- del fkeys_map[excluded]
-
- fkeys_map = fkeys_filter_func(fkeys_map)
-
- fkeys_list = []
- fields = OrderedDict()
-
- fields["start_date"] = forms.DateTimeField(
- required=False,
- label=_("From date"),
- initial=initial.get(
- "start_date", app_settings.SLICK_REPORTING_DEFAULT_START_DATE
- ),
- widget=forms.DateTimeInput(attrs={"autocomplete": "off"}),
- )
-
- fields["end_date"] = forms.DateTimeField(
- required=False,
- label=_("To date"),
- initial=initial.get("end_date", app_settings.SLICK_REPORTING_DEFAULT_END_DATE),
- widget=forms.DateTimeInput(attrs={"autocomplete": "off"}),
- )
-
- if show_time_series_selector:
- time_series_choices = tuple(TIME_SERIES_CHOICES)
- if time_series_selector_allow_empty:
- time_series_choices.insert(0, ("", "---------"))
-
- fields["time_series_pattern"] = forms.ChoiceField(
- required=False,
- initial=time_series_selector_default,
- label=time_series_selector_label or _("Period Pattern"),
- choices=time_series_selector_choices or TIME_SERIES_CHOICES,
- )
-
- for name, f_field in fkeys_map.items():
- fkeys_list.append(name)
- field_attrs = foreign_key_widget_func(f_field)
- if name in required:
- field_attrs["required"] = True
- fields[name] = f_field.formfield(**field_attrs)
-
- if crosstab_model and display_compute_remainder:
- fields["crosstab_compute_remainder"] = forms.BooleanField(
- required=False, label=_("Display the crosstab remainder"), initial=True
- )
-
- bases = (
- BaseReportForm,
- forms.BaseForm,
- )
- new_form = type(
- "ReportForm",
- bases,
- {
- "base_fields": fields,
- "_fkeys": fkeys_list,
- "foreign_keys": fkeys_map,
- "crosstab_model": crosstab_model,
- "crosstab_display_compute_remainder": display_compute_remainder,
- },
- )
- return new_form
+from .forms import * # noqa
diff --git a/slick_reporting/forms.py b/slick_reporting/forms.py
new file mode 100644
index 0000000..d91add6
--- /dev/null
+++ b/slick_reporting/forms.py
@@ -0,0 +1,335 @@
+from collections import OrderedDict
+
+from django import forms
+from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
+
+from . import app_settings
+from .helpers import get_foreign_keys
+
+TIME_SERIES_CHOICES = (
+ ("monthly", _("Monthly")),
+ ("weekly", _("Weekly")),
+ ("annually", _("Yearly")),
+ ("daily", _("Daily")),
+)
+
+
+def default_formfield_callback(f, **kwargs):
+ kwargs["required"] = False
+ kwargs["help_text"] = ""
+ return f.formfield(**kwargs)
+
+
+def get_crispy_helper(
+ foreign_keys_map=None,
+ crosstab_model=None,
+ crosstab_key_name=None,
+ crosstab_display_compute_remainder=False,
+ add_date_range=True,
+):
+ from crispy_forms.helper import FormHelper
+ from crispy_forms.layout import Column, Layout, Div, Row, Field
+
+ foreign_keys_map = foreign_keys_map or []
+ helper = FormHelper()
+ helper.form_class = "form-horizontal"
+ helper.label_class = "col-sm-2 col-md-2 col-lg-2"
+ helper.field_class = "col-sm-10 col-md-10 col-lg-10"
+ helper.form_tag = False
+ helper.disable_csrf = True
+ helper.render_unmentioned_fields = True
+
+ helper.layout = Layout()
+ if add_date_range:
+ helper.layout.fields.append(
+ Row(
+ Column(Field("start_date"), css_class="col-sm-6"),
+ Column(Field("end_date"), css_class="col-sm-6"),
+ css_class="raReportDateRange",
+ ),
+ )
+ filters_container = Div(css_class="mt-20", style="margin-top:20px")
+ # first add the crosstab model and its display reimder then the rest of the fields
+ if crosstab_model:
+ filters_container.append(Field(crosstab_key_name))
+ if crosstab_display_compute_remainder:
+ filters_container.append(Field("crosstab_compute_remainder"))
+
+ for k in foreign_keys_map:
+ if k != crosstab_key_name:
+ filters_container.append(Field(k))
+ helper.layout.fields.append(filters_container)
+
+ return helper
+
+
+class OrderByForm(forms.Form):
+ order_by = forms.CharField(required=False)
+
+ def get_order_by(self, default_field=None):
+ """
+ Get the order by specified by teh form or the default field if provided
+ :param default_field:
+ :return: tuple of field and direction
+ """
+ if self.is_valid():
+ order_field = self.cleaned_data["order_by"]
+ order_field = order_field or default_field
+ if order_field:
+ return self.parse_order_by_field(order_field)
+ return None, None
+
+ def parse_order_by_field(self, order_field):
+ """
+ Specify the field and direction
+ :param order_field: the field to order by
+ :return: tuple of field and direction
+ """
+ if order_field:
+ asc = True
+ if order_field[0:1] == "-":
+ order_field = order_field[1:]
+ asc = False
+ return order_field, not asc
+ return None, None
+
+
+class BaseReportForm:
+ def get_filters(self):
+ raise NotImplementedError(
+ "get_filters() must be implemented in subclass,"
+ "should return a tuple of (Q objects, kwargs filter) to be passed to QuerySet.filter()"
+ )
+
+ def get_start_date(self):
+ raise NotImplementedError(
+ "get_start_date() must be implemented in subclass,"
+ "should return a datetime object"
+ )
+
+ def get_end_date(self):
+ raise NotImplementedError(
+ "get_end_date() must be implemented in subclass,"
+ "should return a datetime object"
+ )
+
+ def get_crosstab_compute_remainder(self):
+ raise NotImplementedError(
+ "get_crosstab_compute_remainder() must be implemented in subclass,"
+ "should return a boolean value"
+ )
+
+ def get_crosstab_ids(self):
+ raise NotImplementedError(
+ "get_crosstab_ids() must be implemented in subclass,"
+ "should return a list of ids to be used for crosstab"
+ )
+
+ def get_time_series_pattern(self):
+ raise NotImplementedError(
+ "get_time_series_pattern() must be implemented in subclass,"
+ "should return a string value of a valid time series pattern"
+ )
+
+ def get_crispy_helper(self):
+ raise NotImplementedError(
+ "get_crispy_helper() must be implemented in subclass,"
+ "should return a crispy helper object"
+ )
+
+
+class SlickReportForm(BaseReportForm):
+ """
+ Holds basic function
+ """
+
+ @property
+ def media(self):
+ from .app_settings import SLICK_REPORTING_FORM_MEDIA
+
+ return forms.Media(
+ css=SLICK_REPORTING_FORM_MEDIA.get("css", {}),
+ js=SLICK_REPORTING_FORM_MEDIA.get("js", []),
+ )
+
+ def get_start_date(self):
+ return self.cleaned_data.get("start_date")
+
+ def get_end_date(self):
+ return self.cleaned_data.get("end_date")
+
+ def get_time_series_pattern(self):
+ return self.cleaned_data.get("time_series_pattern")
+
+ def get_filters(self):
+ """
+ Get the foreign key filters for report queryset, excluding crosstab ids, handled by `get_crosstab_ids()`
+ :return: a dicttionary of filters to be used with QuerySet.filter(**returned_value)
+ """
+ _values = {}
+ if self.is_valid():
+ fk_keys = getattr(self, "foreign_keys", [])
+ if fk_keys:
+ fk_keys = fk_keys.items()
+ for key, field in fk_keys:
+ if key in self.cleaned_data and not key == self.crosstab_key_name:
+ val = self.cleaned_data[key]
+ if val:
+ val = [x for x in val.values_list("pk", flat=True)]
+ _values["%s__in" % key] = val
+ return None, _values
+
+ @cached_property
+ def crosstab_key_name(self):
+ # todo get the model more accurately
+ """
+ return the actual foreignkey field name by simply adding an '_id' at the end.
+ This is hook is to customize this naieve approach.
+ :return: key: a string that should be in self.cleaned_data
+ """
+ return f"{self.crosstab_model}_id"
+
+ def get_crosstab_ids(self):
+ """
+ Get the crosstab ids so they can be sent to the report generator.
+ :return:
+ """
+ if self.crosstab_model:
+ qs = self.cleaned_data.get(self.crosstab_key_name)
+ return [
+ x for x in qs.values_list(self.crosstab_field_related_name, flat=True)
+ ]
+ return []
+
+ def get_crosstab_compute_remainder(self):
+ return self.cleaned_data.get("crosstab_compute_remainder", True)
+
+ def get_crispy_helper(self, foreign_keys_map=None, crosstab_model=None, **kwargs):
+ return get_crispy_helper(
+ self.foreign_keys,
+ crosstab_model=getattr(self, "crosstab_model", None),
+ crosstab_key_name=getattr(self, "crosstab_key_name", None),
+ crosstab_display_compute_remainder=getattr(
+ self, "crosstab_display_compute_remainder", False
+ ),
+ **kwargs,
+ )
+
+
+def _default_foreign_key_widget(f_field):
+ return {
+ "form_class": forms.ModelMultipleChoiceField,
+ "required": False,
+ }
+
+
+def report_form_factory(
+ model,
+ crosstab_model=None,
+ display_compute_remainder=True,
+ fkeys_filter_func=None,
+ foreign_key_widget_func=None,
+ excluded_fields=None,
+ initial=None,
+ required=None,
+ show_time_series_selector=False,
+ time_series_selector_choices=None,
+ time_series_selector_default="",
+ time_series_selector_label=None,
+ time_series_selector_allow_empty=False,
+):
+ """
+ Create a Report Form based on the report_model passed by
+ 1. adding a start_date and end_date fields
+ 2. extract all ForeignKeys on the report_model
+
+ :param model: the report_model
+ :param crosstab_model: crosstab model if any
+ :param display_compute_remainder: relevant only if crosstab_model is specified. Control if we show the check to
+ display the rest.
+ :param fkeys_filter_func: a receives an OrderedDict of Foreign Keys names and their model field instances found on the model, return the OrderedDict that would be used
+ :param foreign_key_widget_func: receives a Field class return the used widget like this {'form_class': forms.ModelMultipleChoiceField, 'required': False, }
+ :param excluded_fields: a list of fields to be excluded from the report form
+ :param initial a dict for fields initial
+ :param required a list of fields that should be marked as required
+ :return:
+ """
+ crosstab_field_related_name = ""
+ foreign_key_widget_func = foreign_key_widget_func or _default_foreign_key_widget
+ fkeys_filter_func = fkeys_filter_func or (lambda x: x)
+
+ # gather foreign keys
+ initial = initial or {}
+ required = required or []
+ fkeys_map = get_foreign_keys(model)
+ excluded_fields = excluded_fields or []
+ for excluded in excluded_fields:
+ del fkeys_map[excluded]
+
+ fkeys_map = fkeys_filter_func(fkeys_map)
+
+ fkeys_list = []
+ fields = OrderedDict()
+
+ fields["start_date"] = forms.DateTimeField(
+ required=False,
+ label=_("From date"),
+ initial=initial.get(
+ "start_date", app_settings.SLICK_REPORTING_DEFAULT_START_DATE
+ ),
+ widget=forms.DateTimeInput(attrs={"autocomplete": "off"}),
+ )
+
+ fields["end_date"] = forms.DateTimeField(
+ required=False,
+ label=_("To date"),
+ initial=initial.get("end_date", app_settings.SLICK_REPORTING_DEFAULT_END_DATE),
+ widget=forms.DateTimeInput(attrs={"autocomplete": "off"}),
+ )
+
+ if show_time_series_selector:
+ time_series_choices = list(TIME_SERIES_CHOICES)
+ if time_series_selector_allow_empty:
+ time_series_choices.insert(0, ("", "---------"))
+
+ fields["time_series_pattern"] = forms.ChoiceField(
+ required=False,
+ initial=time_series_selector_default,
+ label=time_series_selector_label or _("Period Pattern"),
+ choices=time_series_selector_choices or TIME_SERIES_CHOICES,
+ )
+
+ for name, f_field in fkeys_map.items():
+ fkeys_list.append(name)
+ field_attrs = foreign_key_widget_func(f_field)
+ if name in required:
+ field_attrs["required"] = True
+ fields[name] = f_field.formfield(**field_attrs)
+
+ if crosstab_model and display_compute_remainder:
+ fields["crosstab_compute_remainder"] = forms.BooleanField(
+ required=False, label=_("Display the crosstab remainder"), initial=True
+ )
+ crosstab_field_klass = [
+ x for x in model._meta.get_fields() if x.name == crosstab_model
+ ]
+ crosstab_field_related_name = crosstab_field_klass[0].to_fields[0]
+
+ bases = (
+ SlickReportForm,
+ forms.BaseForm,
+ )
+ new_form = type(
+ "ReportForm",
+ bases,
+ {
+ "base_fields": fields,
+ "_fkeys": fkeys_list,
+ "foreign_keys": fkeys_map,
+ "crosstab_model": crosstab_model,
+ "crosstab_display_compute_remainder": display_compute_remainder,
+ "crosstab_field_related_name": crosstab_field_related_name,
+ },
+ )
+ return new_form
diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py
index 6e03c1f..98c8ce9 100644
--- a/slick_reporting/generator.py
+++ b/slick_reporting/generator.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
import datetime
import logging
from dataclasses import dataclass
@@ -41,26 +39,26 @@ def to_dict(self):
)
-class ReportGenerator(object):
- """
- The main class responsible generating the report and managing the flow
- """
-
- field_registry_class = field_registry
- """You can have a custom computation field locator! It only needs a `get_field_by_name(string)`
- and returns a ReportField`"""
-
+class ReportGeneratorAPI:
report_model = None
"""The main model where data is """
+ queryset = None
+ """If set, the report will use this queryset instead of the report_model"""
+
"""
Class to generate a Json Object containing report data.
"""
- date_field = None
+ date_field = ""
"""Main date field to use whenever date filter is needed"""
+ start_date_field_name = None
+ """If set, the report will use this field to filter the start date, default to date_field"""
+
+ end_date_field_name = None
+ """If set, the report will use this field to filter the end date, default to date_field"""
+
print_flag = None
- list_display_links = []
group_by = None
"""The field to use for grouping, if not set then the report is expected to be a sub version of the report model"""
@@ -68,41 +66,41 @@ class ReportGenerator(object):
columns = None
"""A list of column names.
Columns names can be
-
+
1. A Computation Field
-
- 2. If group_by is set, then any field on teh group_by model
-
+
+ 2. If group_by is set, then any field on the group_by model
+
3. If group_by is not set, then any field name on the report_model / queryset
-
+
4. A callable on the generator
-
+
5. Special __time_series__, and __crosstab__
Those can be use to control the position of the time series inside the columns, defaults it's appended at the end
-
+
Example:
columns = ['product_id', '__time_series__', 'col_b']
Same is true with __crosstab__
-
+
You can customize aspects of the column by adding it as a tuple like this
('field_name', dict(verbose_name=_('My Enhanced Verbose_name'))
-
-
+
+
"""
time_series_pattern = ""
"""
If set the Report will compute a time series.
-
+
Possible options are: daily, weekly, semimonthly, monthly, quarterly, semiannually, annually and custom.
-
+
if `custom` is set, you'd need to override `get_custom_time_series_dates`
"""
time_series_columns = None
"""
a list of Calculation Field names which will be included in the series calculation.
Example: ['__total__', '__total_quantity__'] with compute those 2 fields for all the series
-
+
"""
time_series_custom_dates = None
@@ -112,10 +110,13 @@ class ReportGenerator(object):
Example: [ (start_date_1, end_date_1), (start_date_2, end_date_2), ....]
"""
- crosstab_model = None
+ crosstab_model = None # deprecated
+
+ crosstab_field = None
"""
If set, a cross tab over this model selected ids (via `crosstab_ids`)
"""
+
crosstab_columns = None
"""The computation fields which will be computed for each crosstab-ed ids """
@@ -125,25 +126,22 @@ class ReportGenerator(object):
crosstab_compute_remainder = True
"""Include an an extra crosstab_columns for the outer group ( ie: all expects those `crosstab_ids`) """
- show_empty_records = True
- """
- If group_by is set, this option control if the report result will include all objects regardless of appearing in the report_model/qs.
- If set False, only those objects which are found in the report_model/qs
- Example: Say you group by client
- show_empty_records = True will get the computation fields for all clients in the Client model (including those who
- didnt make a transaction.
-
- show_empty_records = False will get the computation fields for all clients in the Client model (including those who
- didnt make a transaction.
-
- """
-
limit_records = None
- """Serves are a main limit to the returned data of teh report_model.
+ """Serves are a main limit to the returned data of the report_model.
Can be beneficial if the results may be huge.
"""
swap_sign = False
+
+class ReportGenerator(ReportGeneratorAPI, object):
+ """
+ The main class responsible generating the report and managing the flow
+ """
+
+ field_registry_class = field_registry
+ """You can have a custom computation field locator! It only needs a `get_field_by_name(string)`
+ and returns a ReportField`"""
+
def __init__(
self,
report_model=None,
@@ -158,7 +156,7 @@ def __init__(
time_series_pattern=None,
time_series_columns=None,
time_series_custom_dates=None,
- crosstab_model=None,
+ crosstab_field=None,
crosstab_columns=None,
crosstab_ids=None,
crosstab_compute_remainder=None,
@@ -170,6 +168,8 @@ def __init__(
limit_records=False,
format_row_func=None,
container_class=None,
+ start_date_field_name=None,
+ end_date_field_name=None,
):
"""
@@ -201,14 +201,21 @@ def __init__(
SLICK_REPORTING_DEFAULT_END_DATE,
)
- super(ReportGenerator, self).__init__()
+ super().__init__()
self.report_model = self.report_model or report_model
- if not self.report_model:
+ if self.queryset is None:
+ self.queryset = main_queryset
+
+ if not self.report_model and self.queryset is None:
raise ImproperlyConfigured(
- "report_model must be set on a class level or via init"
+ "report_model or queryset must be set on a class level or via init"
)
+ main_queryset = (
+ self.report_model.objects if self.queryset is None else self.queryset
+ )
+
self.start_date = start_date or datetime.datetime.combine(
SLICK_REPORTING_DEFAULT_START_DATE.date(),
SLICK_REPORTING_DEFAULT_START_DATE.time(),
@@ -220,10 +227,17 @@ def __init__(
)
self.date_field = self.date_field or date_field
+ self.start_date_field_name = (
+ self.start_date_field_name or start_date_field_name or self.date_field
+ )
+ self.end_date_field_name = (
+ self.end_date_field_name or end_date_field_name or self.date_field
+ )
+
self.q_filters = q_filters or []
self.kwargs_filters = kwargs_filters or {}
+ self.crosstab_field = self.crosstab_field or crosstab_field
- self.crosstab_model = self.crosstab_model or crosstab_model
self.crosstab_columns = crosstab_columns or self.crosstab_columns or []
self.crosstab_ids = self.crosstab_ids or crosstab_ids or []
self.crosstab_compute_remainder = (
@@ -234,8 +248,11 @@ def __init__(
self.format_row = format_row_func or self._default_format_row
- main_queryset = main_queryset or self.report_model.objects
- main_queryset = main_queryset.order_by()
+ main_queryset = (
+ self.report_model.objects if main_queryset is None else main_queryset
+ )
+ # todo revise & move somewhere nicer, List Report need to override the resetting of order
+ main_queryset = self._remove_order(main_queryset)
self.columns = columns or self.columns or []
self.group_by = group_by or self.group_by
@@ -247,11 +264,11 @@ def __init__(
)
self.container_class = container_class
- if not self.date_field and (
- self.time_series_pattern or self.crosstab_model or self.group_by
- ):
+ if not (
+ self.date_field or (self.start_date_field_name and self.end_date_field_name)
+ ) and (self.time_series_pattern or self.crosstab_field or self.group_by):
raise ImproperlyConfigured(
- "date_field must be set on a class level or via init"
+ f"date_field or [start_date_field_name and end_date_field_name] must be set for {self}"
)
self._prepared_results = {}
@@ -298,9 +315,6 @@ def __init__(
self.swap_sign = self.swap_sign or swap_sign
self.limit_records = self.limit_records or limit_records
- # passed to the report fields
- # self.date_field = date_field or self.date_field
-
# in case of a group by, do we show a grouped by model data regardless of their appearance in the results
# a client who didn't make a transaction during the date period.
self.show_empty_records = False # show_empty_records if show_empty_records else self.show_empty_records
@@ -309,39 +323,27 @@ def __init__(
# Preparing actions
self._parse()
if self.group_by:
- if self.show_empty_records:
- pass
- # group_by_filter = self.kwargs_filters.get(self.group_by, '')
- # qs = self.group_by_field.related_model.objects
- # if group_by_filter:
- # lookup = 'pk__in' if isinstance(group_by_filter, Iterable) else 'pk'
- # qs = qs.filter(**{lookup: group_by_filter})
- # self.main_queryset = qs.values()
-
+ self.main_queryset = self._apply_queryset_options(main_queryset)
+ if type(self.group_by_field) is ForeignKey:
+ ids = self.main_queryset.values_list(
+ self.group_by_field_attname
+ ).distinct()
+ # uses the same logic that is in Django's query.py when fields is empty in values() call
+ concrete_fields = [
+ f.name
+ for f in self.group_by_field.related_model._meta.concrete_fields
+ ]
+ # add database columns that are not already in concrete_fields
+ final_fields = concrete_fields + list(
+ set(self.get_database_columns()) - set(concrete_fields)
+ )
+ self.main_queryset = self.group_by_field.related_model.objects.filter(
+ **{f"{self.group_by_field.target_field.name}__in": ids}
+ ).values(*final_fields)
else:
- self.main_queryset = self._apply_queryset_options(main_queryset)
- if type(self.group_by_field) is ForeignKey:
- ids = self.main_queryset.values_list(
- self.group_by_field_attname
- ).distinct()
- # uses the same logic that is in Django's query.py when fields is empty in values() call
- concrete_fields = [
- f.name
- for f in self.group_by_field.related_model._meta.concrete_fields
- ]
- # add database columns that are not already in concrete_fields
- final_fields = concrete_fields + list(
- set(self.get_database_columns()) - set(concrete_fields)
- )
- self.main_queryset = (
- self.group_by_field.related_model.objects.filter(
- pk__in=ids
- ).values(*final_fields)
- )
- else:
- self.main_queryset = self.main_queryset.distinct().values(
- self.group_by_field_attname
- )
+ self.main_queryset = self.main_queryset.distinct().values(
+ self.group_by_field_attname
+ )
else:
if self.time_series_pattern:
self.main_queryset = [{}]
@@ -351,6 +353,16 @@ def __init__(
)
self._prepare_report_dependencies()
+ def _remove_order(self, main_queryset):
+ """
+ Remove order_by from the main queryset
+ :param main_queryset:
+ :return:
+ """
+ # if main_queryset.query.order_by:
+ main_queryset = main_queryset.order_by()
+ return main_queryset
+
def _apply_queryset_options(self, query, fields=None):
"""
Apply the filters to the main queryset which will computed results be mapped to
@@ -361,8 +373,8 @@ def _apply_queryset_options(self, query, fields=None):
filters = {}
if self.date_field:
filters = {
- f"{self.date_field}__gt": self.start_date,
- f"{self.date_field}__lte": self.end_date,
+ f"{self.start_date_field_name}__gt": self.start_date,
+ f"{self.end_date_field_name}__lte": self.end_date,
}
filters.update(self.kwargs_filters)
@@ -378,10 +390,17 @@ def _construct_crosstab_filter(self, col_data):
:param col_data:
:return:
"""
+ if "__" in col_data["crosstab_field"]:
+ column_name = col_data["crosstab_field"]
+ else:
+ field = get_field_from_query_text(
+ col_data["crosstab_field"], self.report_model
+ )
+ column_name = field.column
if col_data["is_remainder"]:
- filters = [~Q(**{f"{col_data['model']}_id__in": self.crosstab_ids})]
+ filters = [~Q(**{f"{column_name}__in": self.crosstab_ids})]
else:
- filters = [Q(**{f"{col_data['model']}_id": col_data["id"]})]
+ filters = [Q(**{f"{column_name}": col_data["id"]})]
return filters
def _prepare_report_dependencies(self):
@@ -436,6 +455,7 @@ def _prepare_report_dependencies(self):
group_by=self.group_by,
report_model=self.report_model,
date_field=self.date_field,
+ queryset=self.queryset,
)
q_filters = None
@@ -545,10 +565,9 @@ def check_columns(cls, columns, group_by, report_model, container_class=None):
:param columns: List of columns
:param group_by: group by field if any
:param report_model: the report model
- :param container_class: a class to search for custom columns attribute in, typically the SlickReportView
+ :param container_class: a class to search for custom columns attribute in, typically the ReportView
:return: List of dict, each dict contains relevant data to the respective field in `columns`
"""
- group_by_field = ""
group_by_model = None
if group_by:
try:
@@ -685,7 +704,7 @@ def get_list_display_columns(self):
except ValueError:
columns += time_series_columns
- if self.crosstab_model:
+ if self.crosstab_field:
crosstab_columns = self.get_crosstab_parsed_columns()
try:
@@ -822,11 +841,11 @@ def get_crosstab_parsed_columns(self):
"name": f"{magic_field_class.name}CT{id}",
"original_name": magic_field_class.name,
"verbose_name": self.get_crosstab_field_verbose_name(
- magic_field_class, self.crosstab_model, id
+ magic_field_class, self.crosstab_field, id
),
"ref": magic_field_class,
"id": id,
- "model": self.crosstab_model,
+ "crosstab_field": self.crosstab_field,
"is_remainder": counter == ids_length
if self.crosstab_compute_remainder
else False,
@@ -860,7 +879,7 @@ def get_metadata(self):
"time_series_column_verbose_names": [
x["verbose_name"] for x in time_series_columns
],
- "crosstab_model": self.crosstab_model or "",
+ "crosstab_model": self.crosstab_field or "",
"crosstab_column_names": [x["name"] for x in crosstab_columns],
"crosstab_column_verbose_names": [
x["verbose_name"] for x in crosstab_columns
@@ -1011,3 +1030,6 @@ def _get_record_data(self, obj, columns):
else:
data[name] = getattr(obj, name, "")
return data
+
+ def _remove_order(self, main_queryset):
+ return main_queryset
diff --git a/slick_reporting/templates/slick_reporting/base.html b/slick_reporting/templates/slick_reporting/base.html
index 950ba91..f97f982 100644
--- a/slick_reporting/templates/slick_reporting/base.html
+++ b/slick_reporting/templates/slick_reporting/base.html
@@ -12,8 +12,6 @@
-