diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3066fb4..3f5e429 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,14 @@ repos: - repo: https://github.com/adamchainz/blacken-docs - rev: "" + rev: "1.13.0" hooks: - id: blacken-docs additional_dependencies: - black==22.12.0 - repo: https://github.com/psf/black - rev: stable + rev: 23.3.0 hooks: - id: black language_version: python3.9 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f70582d..dbca0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [1.0.0] - 2023-07-03 + +- Added crosstab_ids_custom_filters to allow custom filters on crosstab ids +- Added ``group_by_querysets`` to allow custom querysets as group +- Added ability to have crosstab report in a time series report +- Enhanced Docs content and structure. + ## [0.9.0] - 2023-06-07 - Deprecated ``form_factory`` in favor of ``forms``, to be removed next version. diff --git a/README.rst b/README.rst index 7c06eb0..ddc0574 100644 --- a/README.rst +++ b/README.rst @@ -21,15 +21,14 @@ Django Slick Reporting A one stop reports engine with batteries included. -This is project is an extract of the reporting engine of `Django ERP Framework `_ - Features -------- - Effortlessly create Simple, Grouped, Time series and Crosstab reports in a handful of code lines. -- Create your Custom Calculation easily, which will be integrated with the above reports types +- Create Chart(s) for your reports with a single line of code. +- Create Custom complex Calculation. - Optimized for speed. -- Batteries included! Highcharts & Chart.js charting capabilities , DataTable.net & easily customizable Bootstrap form. +- Easily extendable. Installation ------------ @@ -56,7 +55,7 @@ You can simply use a code like this # in views.py from django.db.models import Sum - from slick_reporting.views import ReportView + from slick_reporting.views import ReportView, Chart from slick_reporting.fields import SlickReportField from .models import MySalesItems @@ -73,24 +72,24 @@ You can simply use a code like this ] chart_settings = [ - { - "type": "column", - "data_source": ["sum__value"], - "plot_total": False, - "title_source": "title", - "title": _("Detailed Columns"), - }, + Chart( + "Total sold $", + Chart.BAR, + data_source="value__sum", + title_source="title", + ), ] -To get something like this +To get something this .. image:: https://i.ibb.co/SvxTM23/Selection-294.png :target: https://i.ibb.co/SvxTM23/Selection-294.png :alt: Shipped in View Page -You can do a monthly time series : +Time Series +----------- .. code-block:: python @@ -107,33 +106,69 @@ You can do a monthly time series : group_by = "product" columns = ["name", "sku"] - # Analogy for time series - time_series_pattern = "monthly" + # Settings for creating time series report + time_series_pattern = ( + "monthly" # or "yearly" , "weekly" , "daily" , others and custom patterns + ) time_series_columns = [ - SlickReportField.create(Sum, "quantity", name="sum__quantity") + SlickReportField.create( + Sum, "value", verbose_name=_("Sales Value"), name="value" + ) + ] + + chart_settings = [ + Chart( + _("Total Sales Monthly"), + Chart.PIE, + data_source=["value"], + title_source=["name"], + plot_total=True, + ), ] -This would return a table looking something like this: +.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/timeseries.png?raw=true + :alt: Time Series Report + :align: center + +Cross Tab +--------- + +.. code-block:: python + + # in views.py + from slick_reporting.views import ReportView + from slick_reporting.fields import SlickReportField + from .models import MySalesItems + + + class MyCrosstabReport(ReportView): -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ -| Product Name | SKU | Total Quantity | Total Quantity | Total Quantity in ... | Total Quantity in December 20 | -| | | in Jan 20 | in Feb 20 | | | -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ -| Product 1 | | 10 | 15 | ... | 14 | -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ -| Product 2 | | 11 | 12 | ... | 12 | -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ -| Product 3 | | 17 | 12 | ... | 17 | -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ + crosstab_field = "client" + crosstab_ids = [1, 2, 3] + crosstab_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Value for")), + ] + crosstab_compute_remainder = True -*This example code assumes your "MySalesItems" model contains the fields `product` as foreign key, `quantity` as number , and `date_placed` as a date field. It also assumes your `Product` model has an SKU field.. Change those to better suit your structure.* + columns = [ + "some_optional_field", + # You can customize where the crosstab columns are displayed in relation to the other columns + "__crosstab__", + # 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 + SlickReportField.create(Sum, "value", verbose_name=_("Total Value")), + ] --- +.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/crosstab.png?raw=true + :alt: Homepage + :align: center -**On a low level** +Low level +--------- + +The view is a wrapper over the `ReportGenerator` class, which is the core of the reporting engine. You can interact with the `ReportGenerator` using same syntax as used with the `ReportView` . .. code-block:: python @@ -141,10 +176,18 @@ You can interact with the `ReportGenerator` using same syntax as used with the ` from slick_reporting.generator import ReportGenerator from .models import MySalesModel - report = ReportGenerator( + + class MyReport(ReportGenerator): + report_model = MySalesModel + group_by = "product" + columns = ["title", "__total__"] + + + # OR + my_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}, ] + my_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 @@ -183,7 +226,6 @@ This project is young and can use your support. Some of the ideas / features that ought be added * Support Other backends like SQL Alchemy & Pandas -* Support Time Series and Crosstab at the same time Running tests diff --git a/docs/source/charts.rst b/docs/source/charts.rst index eff6874..3dafcef 100644 --- a/docs/source/charts.rst +++ b/docs/source/charts.rst @@ -1,5 +1,8 @@ -Charting ---------- +Charting and Front End Customization +===================================== + +Charts Configuration +--------------------- Charts settings is a list of objects which each object represent a chart configurations. @@ -15,3 +18,97 @@ Charts settings is a list of objects which each object represent a chart configu 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 +--------------------------- + +Understanding how the response is structured is imperative in order to customize how the report is displayed on the front end + +Let's have a look + +.. code-block:: python + + + # Ajax response or `report_results` template context variable. + response = { + # the report slug, defaults to the class name all lower + "report_slug": "", + # a list of objects representing the actual results of the report + "data": [ + { + "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()) + # 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, + }, + ], + # 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 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", + }, + ], + } + + diff --git a/docs/source/concept.rst b/docs/source/concept.rst index ac80015..33aa12f 100644 --- a/docs/source/concept.rst +++ b/docs/source/concept.rst @@ -1,69 +1,53 @@ .. _structure: -Structure -========== +How the documentation is organized +================================== -If you haven't yet, please check https://django-slick-reporting.com for a quick walk-though with live code examples.. +:ref:`Tutorial ` +-------------------------- + +If you are new to Django Slick Reporting, start here. It's a step-by-step guide to building a simple report(s). -And now, Let's explore the main components of Django Slick Reporting and what setting you can set on project level. -Components ----------- -These are the main components of Django Slick Reporting, ordered from low level to high level: +:ref:`How-to guides ` +----------------------------- -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. +Practical, hands-on guides that show you how to achieve a specific goal with Django Slick Reporting. Like customizing the form, creating a computation field, etc. -2. Generator: The heart of the reporting engine , It's responsible for computing and generating the data and provides low level access. -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. +:ref:`Topic Guides ` +---------------------------- -4. Charting JS helpers: Django slick Reporting comes with highcharts and Charts js helpers libraries to plot the data generated. +Discuss each type of reports you can create with Django Slick Reporting and their options. + * :ref:`Grouped report `: Similar to what we'd do with a GROUP BY sql statement. We group by a field and do some kind of calculations over the grouped records. + * :ref:`time_series`: A step up from the grouped report, where the calculations are computed for each time period (day, week, month, etc). + * :ref:`crosstab_reports`: Where the results shows the relationship between two or more variables. Example: Rows are the clients, columns are the products, and the intersection values are the sum of sales for each client and product combination. This report can be created in time series as well. Example: Rows are the clients, columns are the products, and the intersection values are the sum of sales for each client and product combination, for each month. + * :ref:`list_reports`: Similar to a django changelist, it's a direct view of the report model records with some extra features like sorting, filtering, pagination, etc. + * And other topics like how to customize the form, and extend the exporting options. -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.. +:ref:`Reference ` +---------------------------- -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. +Detailed information about main on Django Slick Reporting's main components, such as the :ref:`Report View `, :ref:`Generator `, :ref:`Computation Field `, etc. -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.. + #. :ref:`Report View `: A ``FormView`` CBV subclass with reporting capabilities allowing you to create different types of reports in the view. + It provide a default :ref:`Filter Form ` to filter the report on. + It mimics the Generator API interface, so knowing one is enough to work with the other. -4. Flat: A report that is not grouped, similar to what an admin list view would show. - For example: Sales Transactions log + #. :ref:`Generator `: Responsible for generating report and orchestrating and calculating the computation fields values and mapping them to the results. + It has an intuitive API that allows you to define the report structure and the computation fields to be calculated. + #. :ref:`Computation Field `: a calculation unit,like a Sum or a Count of a certain field. + Computation field class set how the calculation should be done. ComputationFields can also depend on each other. + #. Charting JS helpers: Highcharts and Charts js helpers libraries to plot the data generated. so you can create the chart in 1 line in the view -Settings --------- -1. ``SLICK_REPORTING_DEFAULT_START_DATE``: Default: the beginning of the current year -2. ``SLICK_REPORTING_DEFAULT_END_DATE``: Default: the end of the current year. -3. ``SLICK_REPORTING_FORM_MEDIA``: Controls the media files required by the search form. - Defaults is: -.. 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", - ), - } +Demo site +--------- -4. ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``: Controls the default chart engine used. +If you haven't yet, please check https://django-slick-reporting.com for a quick walk-though with live code examples.. diff --git a/docs/source/group_by_report.rst b/docs/source/group_by_report.rst deleted file mode 100644 index dd1556e..0000000 --- a/docs/source/group_by_report.rst +++ /dev/null @@ -1,51 +0,0 @@ -================ -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/the_view.rst b/docs/source/howto/customize_frontend.rst similarity index 73% rename from docs/source/the_view.rst rename to docs/source/howto/customize_frontend.rst index 453f640..3dafcef 100644 --- a/docs/source/the_view.rst +++ b/docs/source/howto/customize_frontend.rst @@ -1,37 +1,29 @@ -.. _customization: +Charting and Front End Customization +===================================== -The Slick Report View -===================== +Charts Configuration +--------------------- -What is ReportView? --------------------- +Charts settings is a list of objects which each object represent a chart configurations. -ReportView is a CBV that inherits form ``FromView`` and expose the report generator needed attributes. -It: +* 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. -* 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) -* Print the report in a dedicated format +On front end, for each chart needed we pass the whole response to the relevant chart helper function and it handles the rest. -Export to CSV --------------- -To trigger an export to CSV, just add ``?_export=csv`` to the url. -This will call the export_csv on the view class, engaging a `ExportToStreamingCSV` - -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, ie the view class do not implement ``export_{parameter_name}``, will be ignored. - The ajax response structure --------------------------- -Understanding how the response is structured is imperative in order to customize how the report is displayed on the front end. +Understanding how the response is structured is imperative in order to customize how the report is displayed on the front end Let's have a look @@ -42,7 +34,6 @@ Let's have a look response = { # the report slug, defaults to the class name all lower "report_slug": "", - # a list of objects representing the actual results of the report "data": [ { @@ -59,7 +50,6 @@ Let's have a look }, # etc ..... ], - # A list explaining the columns/keys in the data results. # ie: len(response.columns) == len(response.data[i].keys()) # It contains needed information about verbose name , if summable and hints about the data type. @@ -99,7 +89,6 @@ Let's have a look "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": [ diff --git a/docs/source/howto/index.rst b/docs/source/howto/index.rst new file mode 100644 index 0000000..6389ee6 --- /dev/null +++ b/docs/source/howto/index.rst @@ -0,0 +1,170 @@ +.. _how_to: + +======= +How To +======= +In this section we will go over some of the frequent tasks you will need to do when using ReportView. + + +Customize the form +================== + +The filter form is automatically generated for convenience +but you can override it and add your own 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: return the filters to be used in the report in a tuple + The first element is a list of Q filters (is any) + The second element is a dict of filters to be used in the queryset + These filters will be passed to the report_model.objects.filter(*q_filters, **kw_filters) + +#. get_start_date: return the start date to be used in the report +#. get_end_date: return the end date to be used in the report + + + +.. code-block:: python + + # forms.py + from slick_reporting.forms import BaseReportForm + + class RequestFilterForm(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") + + 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().__init__(*args, **kwargs) + self.fields["start_date"].initial = datetime.date.today() + self.fields["end_date"].initial = datetime.date.today() + + def get_filters(self): + q_filters = [] + kw_filters = {} + + if self.cleaned_data["secure"] == "secure": + kw_filters["is_secure"] = True + elif self.cleaned_data["secure"] == "non-secure": + kw_filters["is_secure"] = False + if self.cleaned_data["method"]: + kw_filters["method"] = self.cleaned_data["method"] + if self.cleaned_data["response"]: + kw_filters["response"] = self.cleaned_data["response"] + if self.cleaned_data["other_people_only"]: + q_filters.append(~Q(user=self.request.user)) + + return q_filters, kw_filters + + def get_start_date(self): + return self.cleaned_data["start_date"] + + def get_end_date(self): + return self.cleaned_data["end_date"] + +For a complete reference of the ``BaseReportForm`` interface, check :ref:`filter_form_customization` + + +Use the report view in our own template +--------------------------------------- +To use the report template with your own project templates, you simply need to override the ``slick_reporting/base.html`` template to make it extends your own base template +You only need to have a ``{% block content %}`` in your base template to be able to use the report template +and a ``{% block extrajs %}`` block to add the javascript implementation. + + +The example below assumes you have a ``base.html`` template in your project templates folder and have a content block and a project_extrajs block in it. + +.. code-block:: html + + {% extends "base.html" %} + {% load static %} + + {% block content %} + + {% endblock %} + + {% block project_extrajs %} + {% include "slick_reporting/js_resources.html" %} + {% block extrajs %} + {% endblock %} + + {% endblock %} + + +Work with tree data & Nested categories +--------------------------------------- + + + + + +Change the report structure in response to User input +----------------------------------------------------- + + +Create your own Chart Engine +----------------------------- + +Create a Custom ComputationField and reuse it +--------------------------------------------- + + + +Add a new chart engine +---------------------- + + +Add an exporting option +----------------------- + + + +Work with categorical data +-------------------------- + +How to create a custom ComputationField +--------------------------------------- + + +create custom columns +--------------------- + + +format numbers in the datatable + + +custom group by +custom time series periods +custom crosstab reports + +.. toctree:: + :maxdepth: 2 + :caption: Topics: + :titlesonly: + + + customize_frontend + + diff --git a/docs/source/howto/override_filter_form.rst b/docs/source/howto/override_filter_form.rst new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/index.rst b/docs/source/index.rst index 353d868..de1d877 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,7 +1,12 @@ Django Slick Reporting ====================== -**Django Slick Reporting** a report engine allowing you to create & display diverse analytics. Batteries like a ready to use View and Highcharts & Charts.js integration are included. +**Django Slick Reporting** a reporting engine allowing you to create & display diverse analytics. Batteries like a ready to use View and Highcharts & Charts.js integration are included. + +* Create group by , crosstab , timeseries, crosstab in timeseries and list reports in handful line with intuitive syntax +* Highcharts & Charts.js integration ready to use with the shipped in View, easily extendable to use with your own charts. +* Export to CSV +* Easily extendable to add your own computation fields, Installation @@ -11,21 +16,10 @@ To install django-slick-reporting: 1. Install with pip: `pip install django-slick-reporting`. 2. Add ``slick_reporting`` to ``INSTALLED_APPS``. -3. For the shipped in View, add ``'crispy_forms'`` to ``INSTALLED_APPS`` and add ``CRISPY_TEMPLATE_PACK = 'bootstrap4'`` to your ``settings.py`` +3. For the shipped in View, add ``'crispy_forms'`` to ``INSTALLED_APPS`` + and add ``CRISPY_TEMPLATE_PACK = 'bootstrap4'`` to your ``settings.py`` 4. Execute `python manage.py collectstatic` so the JS helpers are collected and served. -Demo site ----------- - -https://django-slick-reporting.com is a quick walk-though with live code examples - -Options -------- -* Compute different types of fields (Sum, Avg, Count, Min, Max, StdDev, Variance) on a model -* Group by a foreign key, date, or any other field -* Display the results in a table -* Display the results in a chart (Highcharts or Charts.js) -* Export the results to CSV , extendable easily Quickstart @@ -37,23 +31,16 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene # in views.py from slick_reporting.views import ReportView - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import SlickReportField, Chart from .models import MySalesItems - 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' - # this support traversing, like so - # date_field = 'order__date_placed' + class ProductSales(ReportView): - # A foreign key to group calculation on + report_model = MySalesItems + date_field = "date_placed" group_by = "product" - # The columns you want to display columns = [ "title", SlickReportField.create( @@ -63,31 +50,43 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene # Charts charts_settings = [ - { - "type": "bar", - "data_source": "value__sum", - "title_source": "title", - }, + Chart( + "Total sold $", + Chart.BAR, + data_source="value__sum", + title_source="title", + ), ] -Next step :ref:`structure` + # in urls.py + from django.urls import path + from .views import ProductSales + + urlpatterns = [ + path("product-sales/", ProductSales.as_view(), name="product-sales"), + ] + +Demo site +---------- + +https://django-slick-reporting.com is a quick walk-though with live code examples + + + +Next step :ref:`tutorial` .. toctree:: :maxdepth: 2 :caption: Contents: concept - the_view - view_options - group_by_report - time_series_options - crosstab_options - list_report_options - search_form + tutorial + howto/index + topics/index charts - report_generator - computation_field + ref/index + Indices and tables diff --git a/docs/source/recipes.rst b/docs/source/recipes.rst deleted file mode 100644 index 5ba3be5..0000000 --- a/docs/source/recipes.rst +++ /dev/null @@ -1,19 +0,0 @@ -.. _recipes: - -Recipes -======= - -Remove a field from the generated Filter Form ----------------------------------------------- - - -Alter the format for end results ---------------------------------- - -Like showing a properly formatted date instead of the ISO. - - -Add fields to the the generated report form -------------------------------------------- - - diff --git a/docs/source/computation_field.rst b/docs/source/ref/computation_field.rst similarity index 100% rename from docs/source/computation_field.rst rename to docs/source/ref/computation_field.rst diff --git a/docs/source/ref/index.rst b/docs/source/ref/index.rst new file mode 100644 index 0000000..8d5f939 --- /dev/null +++ b/docs/source/ref/index.rst @@ -0,0 +1,16 @@ +.. _reference: + +Reference +=========== + +Below are links to the reference documentation for the various components of the Django slick reporting . + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + computation_field + report_generator + view_options + + diff --git a/docs/source/report_generator.rst b/docs/source/ref/report_generator.rst similarity index 100% rename from docs/source/report_generator.rst rename to docs/source/ref/report_generator.rst diff --git a/docs/source/ref/settings.rst b/docs/source/ref/settings.rst new file mode 100644 index 0000000..20396bd --- /dev/null +++ b/docs/source/ref/settings.rst @@ -0,0 +1,29 @@ + +Settings +======== + + +1. ``SLICK_REPORTING_DEFAULT_START_DATE``: Default: the beginning of the current year +2. ``SLICK_REPORTING_DEFAULT_END_DATE``: Default: the end of the current year. +3. ``SLICK_REPORTING_FORM_MEDIA``: Controls the media files required by the search form. + Defaults is: + +.. 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", + ), + } + +4. ``SLICK_REPORTING_DEFAULT_CHARTS_ENGINE``: Controls the default chart engine used. diff --git a/docs/source/view_options.rst b/docs/source/ref/view_options.rst similarity index 75% rename from docs/source/view_options.rst rename to docs/source/ref/view_options.rst index a7fd448..a98ce92 100644 --- a/docs/source/view_options.rst +++ b/docs/source/ref/view_options.rst @@ -1,27 +1,20 @@ -Report View Options -=================== +.. _report_view_options: -We can categorize the output of a report into 4 sections: +General Options +================ -#. 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. +Below is the list of general options that is used across all types of reports. -``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. + 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()`` + The queryset to be used in the report, if not specified, it will default to ``report_model._default_manager.all()`` .. attribute:: ReportView.columns @@ -34,21 +27,21 @@ Below is the general list of options that can be used to control the behavior of class MyReport(ReportView): columns = [ - 'id', - ('name', {'verbose_name': "My verbose name", "is_summable"=False}), - 'description', - + "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} ), + ("get_full_name", {"verbose_name": "Full Name", "is_summable": False}), ] def get_full_name(self, record): - return record['first_name'] + " " + record['last_name'] + return record["first_name"] + " " + record["last_name"] + + Here is a list of all available column options available. A column can be - Columns names can be + * A Computation Field. Added as a class or by its name if its registered see :ref:`computation_field` - * A Computation Field, as a class or by its name if its registered (see :ref:`computation_field`) Example: .. code-block:: python @@ -56,17 +49,18 @@ Below is the general list of options that can be used to control the behavior of class MyTotalReportField(SlickReportField): pass + class MyReport(ReportView): columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), # a computation field created on the fly + SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), - MyTotalReportField, # A computation Field class + MyTotalReportField, - "__total__", # a computation field registered in the computation field registry - ] + "__total__", + ] @@ -79,12 +73,11 @@ Below is the general list of options that can be used to control the behavior of class MyReport(ReportView): report_model = MySales - group_by = 'client' + 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 - + "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 ] @@ -99,11 +92,11 @@ Below is the general list of options that can be used to control the behavior of .. code-block:: python class MyReport(ReportView): - report_model = MySales - group_by = None - columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value") - ] + 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 @@ -137,7 +130,7 @@ Below is the general list of options that can be used to control the behavior of class MyReport(ReportView): report_model = MySalesModel - group_by = 'client' + group_by = "client" # OR # group_by = 'client__agent__name' # OR @@ -229,7 +222,7 @@ Below is the general list of options that can be used to control the behavior of Hooks and functions -------------------- +==================== .. attribute:: ReportView.get_queryset() diff --git a/docs/source/time_series_options.rst b/docs/source/time_series_options.rst deleted file mode 100644 index bb07467..0000000 --- a/docs/source/time_series_options.rst +++ /dev/null @@ -1,90 +0,0 @@ -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 - -.. code-block:: python - - from django.utils.translation import gettext_lazy as _ - from django.db.models import Sum - from slick_reporting.views import ReportView - - class MyReport(ReportView): - - time_series_pattern = "monthly" - # options are : "daily", "weekly", "monthly", "yearly", "custom" - - # if time_series_pattern is "custom", then you can specify the dates like so - # time_series_custom_dates = [ - # (datetime.date(2020, 1, 1), datetime.date(2020, 1, 14)), - # (datetime.date(2020, 2, 1), datetime.date(2020, 2, 14)), - # (datetime.date(2020, 3, 1), datetime.date(2020, 3,14)), - ] - - - time_series_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - ] - # These columns will be calculated for each period in the time series. - - - - columns = ['some_optional_field', - '__time_series__', - # You can customize where the time series columns are displayed in relation to the other columns - - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - # This is the same as the time_series_columns, but this one will be on the whole set - - ] - - 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_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 -------------- - -Time series Selector pattern Demo `Demo `_ -and here is the `Code on github `_ for the report. diff --git a/docs/source/_static/crosstab.png b/docs/source/topics/_static/crosstab.png similarity index 100% rename from docs/source/_static/crosstab.png rename to docs/source/topics/_static/crosstab.png diff --git a/docs/source/_static/group_report.png b/docs/source/topics/_static/group_report.png similarity index 100% rename from docs/source/_static/group_report.png rename to docs/source/topics/_static/group_report.png diff --git a/docs/source/_static/list_view_form.png b/docs/source/topics/_static/list_view_form.png similarity index 100% rename from docs/source/_static/list_view_form.png rename to docs/source/topics/_static/list_view_form.png diff --git a/docs/source/topics/_static/timeseries.png b/docs/source/topics/_static/timeseries.png new file mode 100644 index 0000000..43202bf Binary files /dev/null and b/docs/source/topics/_static/timeseries.png differ diff --git a/docs/source/crosstab_options.rst b/docs/source/topics/crosstab_options.rst similarity index 66% rename from docs/source/crosstab_options.rst rename to docs/source/topics/crosstab_options.rst index 1cbaaa4..7e6e26a 100644 --- a/docs/source/crosstab_options.rst +++ b/docs/source/topics/crosstab_options.rst @@ -1,3 +1,5 @@ +.. _crosstab_reports: + Crosstab Reports ================= Use crosstab reports, also known as matrix reports, to show the relationships between three or more query items. @@ -13,7 +15,7 @@ Here is a simple example of a crosstab report: class MyCrosstabReport(ReportView): - + group_by = "product" crosstab_field = "client" # the column you want to make a crosstab on, can be a foreign key or a choice field @@ -21,9 +23,10 @@ Here is a simple example of a crosstab report: 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] + crosstab_ids = [ + 1, + 2, + ] # a list of ids of the crosstab field you want to use. This will be passed on by the filter form, or , if set here, values here will be used. # OR in case of a choice / text field # crosstab_ids = ["my-choice-1", "my-choice-2", "my-choice-3"] @@ -32,13 +35,58 @@ Here is a simple example of a crosstab report: # 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", + "name", + "sku", "__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 crosstab ids +---------------------------- + +For more fine tuned report, You can customize the ids of the crosstab report by suppling a list of tuples to the ``crosstab_ids_custom_filters`` attribute. +the tuple should have 2 items, the first is a Q object(s) -if any- , and the second is a dict of kwargs filters that will be passed to the filter method of the ``report_model``. + +Example: + +.. code-block:: python + + from .models import MySales + + + class MyCrosstabReport(ReportView): + + date_field = "date" + group_by = "product" + report_model = MySales + + crosstab_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Value")), + ] + + crosstab_ids_custom_filters = [ + ( + ~Q(special_field="something"), + dict(flag="sales"), + ), # special_field and flag are fields on the report_model . + (None, dict(flag="sales-return")), + ] + + # These settings has NO EFFECT if crosstab_ids_custom_filters is set + crosstab_field = "client" + crosstab_ids = [1, 2] + crosstab_compute_remainder = True + + + +Having Time Series Crosstab Reports +----------------------------------- +You can have a crosstab report in a time series by setting the :ref:`time_series_options` in addition to the crosstab options. + + 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. @@ -98,4 +146,4 @@ The above code would return a result like this: 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 +3. The remainder. In this example, it is the rest of the clients. crosstab_compute_remainder was set to True diff --git a/docs/source/topics/exporting.rst b/docs/source/topics/exporting.rst new file mode 100644 index 0000000..317b491 --- /dev/null +++ b/docs/source/topics/exporting.rst @@ -0,0 +1,16 @@ +Exporting +========= + +Exporting to CSV +----------------- +To trigger an export to CSV, just add ``?_export=csv`` to the url. This is performed by by the Export to CSV button in the default form. + +This will call the export_csv on the view class, engaging a `ExportToStreamingCSV` + +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, ie the view class do not implement ``export_{parameter_name}``, will be ignored. + + diff --git a/docs/source/search_form.rst b/docs/source/topics/filter_form.rst similarity index 90% rename from docs/source/search_form.rst rename to docs/source/topics/filter_form.rst index 3ce76dc..2e540ef 100644 --- a/docs/source/search_form.rst +++ b/docs/source/topics/filter_form.rst @@ -1,28 +1,21 @@ .. _filter_form: -Filter Form -=========== +Customizing 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 ? ------------------------------------ +The generated form +------------------- + 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. +Changing the generated form API is still private, however, you can use your own form easily. -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 ------------------- +Overriding the Form +-------------------- The system expect that the form used with the ``ReportView`` to implement the ``slick_reporting.forms.BaseReportForm`` interface. @@ -120,9 +113,11 @@ Example a full example of a custom form: return self.cleaned_data["end_date"] # ---- - # in reports.py - @register_report_view + # in views.py + + from .forms import RequestLogForm + class RequestCountByPath(ReportView): form_class = RequestLogForm -You can view this code snippet in action on the demo project +You can view this code snippet in action on the demo project https://my-shop.django-erp-framework.com/requests-dashboard/reports/request_analytics/requestlog/ diff --git a/docs/source/topics/group_by_report.rst b/docs/source/topics/group_by_report.rst new file mode 100644 index 0000000..88bdc45 --- /dev/null +++ b/docs/source/topics/group_by_report.rst @@ -0,0 +1,90 @@ +.. _group_by_topic: + +================ +Group By Reports +================ + +General use case +---------------- + +Group by reports are reports that group the data by a specific field, while doing some kind of calculation on the grouped fields. 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" + ), + ] + +A Sample group by report would look like this: + +.. image:: _static/group_report.png + :width: 800 + :alt: Group Report + :align: center + +In the columns you can access to fields on the model that is being grouped by, in this case the Expense model, and the computation fields. + +Group by a traversing field +--------------------------- + +``group_by`` value can be a traversing field. If set, the report will be grouped by the last field in the traversing path, + and, the columns available will be those of 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"), + ] + + + +Group by custom querysets +------------------------- + +Grouping can also be over a curated queryset(s). + +Example: + +.. code-block:: python + + class MyReport(ReportView): + report_model = MySales + + group_by_querysets = [ + MySales.objects.filter(status="pending"), + MySales.objects.filter(status__in=["paid", "overdue"]), + ] + group_by_custom_querysets_column_verbose_name = _("Status") + + columns = [ + "__index__", + SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), + ] + +This report will create two groups, one for pending sales and another for paid and overdue together. + +The ``__index__`` column is a "magic" column, it will added automatically to the report if it's not added. +It just hold the index of the row in the group. +its verbose name (ie the one on the table header) can be customized via ``group_by_custom_querysets_column_verbose_name`` + +You can then customize the *value* of the __index__ column via ``filter_results`` hook diff --git a/docs/source/topics/index.rst b/docs/source/topics/index.rst new file mode 100644 index 0000000..5b2f26f --- /dev/null +++ b/docs/source/topics/index.rst @@ -0,0 +1,36 @@ +.. _topics: + +Topics +====== + +ReportView is a ``django.views.generic.FromView`` subclass that exposes the **Report Generator API** allowing you to create a report seamlessly in a view. + +* Exposes the report generation options in the view class. +* Auto generate the filter form based on the report model, or uses your custom form to generate and filter the report. +* Return an html page prepared to display the results in a table and charts. +* Export to CSV, which is extendable to apply other exporting methods. (like yaml or other) +* Print the report in a dedicated page design. + + +You saw how to use the ReportView class in the tutorial and you identified the types of reports available, in the next section we will go in depth about: + +#. Each type of the reports and its options. +#. The general options available for all report types +#. How to customize the Form +#. How to customize exports and print. + + +.. toctree:: + :maxdepth: 2 + :caption: Topics: + :titlesonly: + + + group_by_report + time_series_options + crosstab_options + list_report_options + filter_form + exporting + + diff --git a/docs/source/list_report_options.rst b/docs/source/topics/list_report_options.rst similarity index 68% rename from docs/source/list_report_options.rst rename to docs/source/topics/list_report_options.rst index 152ed03..f424f23 100644 --- a/docs/source/list_report_options.rst +++ b/docs/source/topics/list_report_options.rst @@ -1,8 +1,11 @@ -List Report View -================ +.. _list_reports: -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. +List Reports +============ + + +It's a simple ListView / admin changelist like report to display data in a model. +It's quite similar to ReportView except there is no calculation by default. Options: -------- diff --git a/docs/source/topics/time_series_options.rst b/docs/source/topics/time_series_options.rst new file mode 100644 index 0000000..e54c6fb --- /dev/null +++ b/docs/source/topics/time_series_options.rst @@ -0,0 +1,103 @@ +.. _time_series: + +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 + +.. code-block:: python + + from django.utils.translation import gettext_lazy as _ + from django.db.models import Sum + from slick_reporting.views import ReportView + + + class MyReport(ReportView): + + # options are : "daily", "weekly", "monthly", "yearly", "custom" + time_series_pattern = "monthly" + + + # if time_series_pattern is "custom", then you can specify the dates like so + time_series_custom_dates = [ + (datetime.date(2020, 1, 1), datetime.date(2020, 1, 14)), + (datetime.date(2020, 2, 1), datetime.date(2020, 2, 14)), + (datetime.date(2020, 3, 1), datetime.date(2020, 3,14)), + ] + + # These columns will be calculated for each period in the time series. + time_series_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Value")), + ] + + + columns = [ + "product_sku", + + # You can customize where the time series columns are displayed in relation to the other columns + "__time_series__", + + # This is the same as the time_series_columns, but this one will be on the whole set + SlickReportField.create(Sum, "value", verbose_name=_("Value")), + + ] + + # This will display a selector to change the time series pattern + time_series_selector = True + + # settings for the time series selector + # ---------------------------------- + time_series_selector_choices = None # A list Choice tuple [(value, label), ...] + time_series_selector_default = ( + "monthly" # The initial value for the time series selector + ) + # The label for the time series selector + time_series_selector_label = _("Period Pattern") + # Allow the user to select an empty time series + time_series_selector_allow_empty = False + + +.. _time_series_options: + +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 +'''''''''''''' + +Time series Selector pattern `Demo `_ +and the `Code on github `_ for it. diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst new file mode 100644 index 0000000..dc4da18 --- /dev/null +++ b/docs/source/tutorial.rst @@ -0,0 +1,397 @@ +.. _tutorial: + +========= +Tutorial +========= + +In this tutorial we will go over how to create different reports using Slick Reporting and integrating them into your projects. + +Let' say you have a Sales Transaction model in your project. Schema looking like this: + +.. code-block:: python + + from django.db import models + from django.utils.translation import gettext_lazy as _ + + + class Client(models.Model): + name = models.CharField(_("Name"), max_length=255) + country = models.CharField(_("Country"), max_length=255, default="US") + + + class Product(models.Model): + name = models.CharField(_("Name"), max_length=255) + sku = models.CharField(_("SKU"), max_length=255) + + + class Sales(models.Model): + doc_date = models.DateTimeField(_("date"), db_index=True) + client = models.ForeignKey(Client, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + quantity = models.DecimalField( + _("Quantity"), max_digits=19, decimal_places=2, default=0 + ) + price = models.DecimalField(_("Price"), max_digits=19, decimal_places=2, default=0) + value = models.DecimalField(_("Value"), max_digits=19, decimal_places=2, default=0) + + + +Now, you want to extract the following information from that sales model, present to your users in a nice table and chart: + +#. Total sales per product. +#. Total Sales per client country. +#. Total sales per product each month. +#. Total Sales per product and country. +#. Total Sales per product and country, per month. +#. Display last 10 sales transactions. + +Group By Reports +================ + +1. Total sales per product +-------------------------- + +This can be done via an SQL statement looking like this: + +.. code-block:: sql + + SELECT product_id, SUM(value) FROM sales GROUP BY product_id; + +In Slick Reporting, you can do the same thing by creating a report view looking like this: + +.. code-block:: python + + # in views.py + + from django.db.models import Sum + from slick_reporting.views import ReportView, Chart + from slick_reporting.fields import SlickReportField + from .models import Sales + + + class TotalProductSales(ReportView): + + report_model = Sales + date_field = "doc_date" + group_by = "product" + columns = [ + "title", + SlickReportField.create(Sum, "quantity", "verbose_name": "Total quantity sold", "is_summable": False), + SlickReportField.create(Sum, "value", name="sum__value", "verbose_name": "Total Value sold $"), + ] + + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source="value__sum", + title_source="title", + ), + ] + +Then in your urls.py add the following: + +.. code-block:: python + + from django.urls import path + from .views import TotalProductSales + + urlpatterns = [ + path( + "total-product-sales/", TotalProductSales.as_view(), name="total-product-sales" + ), + ] + +Now visit the url ``/total-product-sales/`` and you will see the page report. Containing a Filter Form, the report table and a chart. + + +You can change the dates in the filter form , add some filters and the report will be updated. +You can also export the report to CSV. + +2. Total Sales per each client country +-------------------------------------- + +.. code-block:: python + + # in views.py + + from django.db.models import Sum + from slick_reporting.views import ReportView, Chart + from slick_reporting.fields import SlickReportField + from .models import Sales + + + class TotalProductSales(ReportView): + + report_model = Sales + date_field = "doc_date" + group_by = "client__country" # notice the double underscore + columns = [ + "country", + SlickReportField.create(Sum, "value", name="sum__value"), + ] + + chart_settings = [ + Chart( + "Total sold $", + Chart.PIE, # A Pie Chart + data_source="value__sum", + title_source="country", + ), + ] + + +Time Series Reports +==================== +A time series report is a report that computes the data for each period of time. For example, if you want to see the total sales per each month, then you need to create a time series report. + + + +.. code-block:: python + + from slick_reporting.fields import SlickReportField + + + class SumValueComputationField(SlickReportField): + computation_method = Sum + computation_field = "value" + verbose_name = _("Sales Value") + + + class MonthlyProductSales(ReportView): + report_model = Sales + date_field = "doc_date" + group_by = "product" + columns = ["name", "sku"] + + time_series_pattern = "monthly" + time_series_columns = [ + SumValueComputationField, + ] + + chart_settings = [ + Chart( + _("Total Sales Monthly"), + Chart.PIE, + data_source=["value"], + title_source=["name"], + plot_total=True, + ), + ] + +then again in your urls.py add the following: + +.. code-block:: python + + from django.urls import path + from .views import MonthlyProductSales + + urlpatterns = [ + path( + "monthly-product-sales/", + MonthlyProductSales.as_view(), + name="monthly-product-sales", + ), + ] + +Note: We created SumValueComputationField to avoid repeating the same code in each report. You can create your own ``ComputationFields`` and use them in your reports. + +Pretty Cool yes ? + +CrossTab Reports +================ +A crosstab report shows the relation between two or more variables. For example, if you want to see the total sales per each product and country, then you need to create a crosstab report. + +.. code-block:: python + + + class ProductSalesPerCountry(ReportView): + report_model = Sales + date_field = "doc_date" + group_by = "product" + crosstab_field = "client__country" + + crosstab_columns = [ + SumValueComputationField, + ] + + crosstab_ids = ["US", "KW", "EG", "DE"] + crosstab_compute_remainder = True + + columns = [ + "name", + "sku", + "__crosstab__", + SumValueComputationField, + ] + +Then again in your urls.py add the following: + +.. code-block:: python + + from django.urls import path + from .views import MyCrosstabReport + + urlpatterns = [ + path( + "product-sales-per-country/", + ProductSalesPerCountry.as_view(), + name="product-sales-per-country", + ), + ] + + +List Reports +============ +A list report is a report that shows a list of records. For example, if you want to see the last 10 sales transactions, then you need to create a list report. + +.. code-block:: python + + from slick_reporting.view import ListReportView + + + class LastTenSales(ListReportView): + report_model = Sales + date_field = "doc_date" + group_by = "product" + columns = [ + "product__name", + "product__sku", + "doc_date", + "quantity", + "price", + "value", + ] + default_order_by = "-doc_date" + limit_records = 10 + + +Then again in your urls.py add the following: + +.. code-block:: python + + from django.urls import path + from .views import LastTenSales + + urlpatterns = [ + path( + "last-ten-sales/", + LastTenSales.as_view(), + name="last-ten-sales", + ), + ] + +Integrate the view in your project +=================================== + +You can use the template in your own project by following these steps: + +#. Override ``slick_reporting/base.html`` in your own project and make it extends you own base template. +#. Make sure your base template has a ``{% block content %}`` block and a ``{% block extrajs %}`` block. +#. Add the slick reporting js resources to the page by adding `{% include "slick_reporting/js_resources.html" %}` to an appropriate block. + + +Overriding 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) + +For detailed information about the form, please check :ref:`filter_form` + +Example +------- + +.. code-block:: python + + # forms.py + from slick_reporting.forms import BaseReportForm + from crispy_forms.helper import FormHelper + + # 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" + ) + other_people_only = forms.BooleanField( + required=False, label="Show requests from other users only" + ) + + + def get_filters(self): + # return the filters to be used in the report + # Note: the use of Q filters and kwargs filters + kw_filters = {} + q_filters = [] + if self.cleaned_data["secure"] == "secure": + kw_filters["is_secure"] = True + elif self.cleaned_data["secure"] == "non-secure": + kw_filters["is_secure"] = False + if self.cleaned_data["other_people_only"]: + q_filters.append(~Q(user=self.request.user)) + return q_filters, kw_filters + + def get_start_date(self): + return self.cleaned_data["start_date"] + + def get_end_date(self): + return self.cleaned_data["end_date"] + + def get_crispy_helper(self): + return FormHelper() + + +Recap +===== +In the tutorial we went over how to create a report using the ``ReportView`` and ``ListReportView`` classes. +The different types of reports we created are: + +1. Grouped By Reports +2. Time Series Reports +3. Crosstab Reports +4. List Reports + +You can create a report by inheriting from ``ReportView`` or ``ListReportView`` and setting the following attributes: + +* ``report_model``: The model to be used in the report +* ``date_field``: The date field to be used in the report +* ``columns``: The columns to be displayed in the report +* ``default_order_by``: The default order by for the report +* ``limit_records``: The limit of records to be displayed in the report +* ``group_by``: The field to be used to group the report by +* ``time_series_pattern``: The time series pattern to be used in the report +* ``time_series_columns``: The columns to be displayed in the time series report +* ``crosstab_field``: The field to be used to create a crosstab report +* ``crosstab_columns``: The columns to be displayed in the crosstab report +* ``crosstab_ids``: The ids to be used in the crosstab report +* ``crosstab_compute_remainder``: Whether to compute the remainder in the crosstab report +* ``chart_settings``: The chart settings to be used in the report + +We also saw how you can customize the form used in the report by inheriting from ``BaseReportForm``, and integrating the view in your project. diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py index e755575..c4deea6 100644 --- a/slick_reporting/__init__.py +++ b/slick_reporting/__init__.py @@ -1,5 +1,5 @@ default_app_config = "slick_reporting.apps.ReportAppConfig" -VERSION = (0, 9, 0) +VERSION = (1, 0, 0) -__version__ = "0.9.0" +__version__ = "1.0.0" diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py index c7aba3c..e066480 100644 --- a/slick_reporting/fields.py +++ b/slick_reporting/fields.py @@ -43,6 +43,7 @@ class SlickReportField(object): """The queryset on which the computation would occur""" group_by = None + group_by_custom_querysets = None plus_side_q = None minus_side_q = None @@ -105,6 +106,7 @@ def __init__( calculation_method=None, date_field="", group_by=None, + group_by_custom_querysets=None, ): super(SlickReportField, self).__init__() self.date_field = date_field @@ -116,6 +118,10 @@ def __init__( else self.queryset ) + self.group_by_custom_querysets = ( + self.group_by_custom_querysets or group_by_custom_querysets + ) + self.calculation_field = ( calculation_field if calculation_field else self.calculation_field ) @@ -148,7 +154,12 @@ def apply_q_minus_filter(self, qs): def apply_aggregation(self, queryset, group_by=""): annotation = self.calculation_method(self.calculation_field) - if group_by: + if self.group_by_custom_querysets: + output = [] + for group_by_query in self.group_by_custom_querysets: + output.append(group_by_query.aggregate(annotation)) + return output + elif group_by: queryset = queryset.values(group_by).annotate(annotation) else: queryset = queryset.aggregate(annotation) @@ -170,7 +181,6 @@ def init_preparation(self, q_filters=None, kwargs_filters=None, **kwargs): q_filters, kwargs_filters, **kwargs ) self._cache = debit_results, credit_results, dep_values - return self._cache def prepare(self, q_filters=None, kwargs_filters=None, **kwargs): """ @@ -285,7 +295,11 @@ def _resolve_dependencies(self, current_obj, name=None): return dep_results def extract_data(self, cached, current_obj): - group_by = "" if self.prevent_group_by else self.group_by + group_by = ( + "" + if self.prevent_group_by + else (self.group_by or self.group_by_custom_querysets) + ) debit_value = 0 credit_value = 0 annotation = self.get_annotation_name() @@ -298,6 +312,9 @@ def extract_data(self, cached, current_obj): if not group_by: x = list(cached_debit.keys())[0] debit_value = cached_debit[x] + elif self.group_by_custom_querysets: + debit = cached_debit[int(current_obj)] + debit_value = debit[annotation] else: for i, x in enumerate(cached_debit): if str(x[group_by]) == current_obj: diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index 98c8ce9..c8f4d40 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -63,6 +63,10 @@ class ReportGeneratorAPI: 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""" + group_by_custom_querysets = None + """A List of querysets representing different group by options""" + group_by_custom_querysets_column_verbose_name = "" + columns = None """A list of column names. Columns names can be @@ -123,6 +127,8 @@ class ReportGeneratorAPI: crosstab_ids = None """A list is the ids to create a crosstab report on""" + crosstab_ids_custom_filters = None + crosstab_compute_remainder = True """Include an an extra crosstab_columns for the outer group ( ie: all expects those `crosstab_ids`) """ @@ -152,6 +158,7 @@ def __init__( q_filters=None, kwargs_filters=None, group_by=None, + group_by_custom_querysets=None, columns=None, time_series_pattern=None, time_series_columns=None, @@ -159,6 +166,7 @@ def __init__( crosstab_field=None, crosstab_columns=None, crosstab_ids=None, + crosstab_ids_custom_filters=None, crosstab_compute_remainder=None, swap_sign=False, show_empty_records=None, @@ -240,6 +248,10 @@ def __init__( self.crosstab_columns = crosstab_columns or self.crosstab_columns or [] self.crosstab_ids = self.crosstab_ids or crosstab_ids or [] + self.crosstab_ids_custom_filters = ( + self.crosstab_ids_custom_filters or crosstab_ids_custom_filters or [] + ) + self.crosstab_compute_remainder = ( self.crosstab_compute_remainder if crosstab_compute_remainder is None @@ -257,6 +269,10 @@ def __init__( self.columns = columns or self.columns or [] self.group_by = group_by or self.group_by + self.group_by_custom_querysets = ( + group_by_custom_querysets or self.group_by_custom_querysets or [] + ) + self.time_series_pattern = self.time_series_pattern or time_series_pattern self.time_series_columns = self.time_series_columns or time_series_columns self.time_series_custom_dates = ( @@ -315,15 +331,18 @@ def __init__( self.swap_sign = self.swap_sign or swap_sign self.limit_records = self.limit_records or limit_records - # 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. + # todo delete this self.show_empty_records = False # show_empty_records if show_empty_records else self.show_empty_records - # Looks like this options is harder then what i thought as it interfere with the usual filtering of the report # Preparing actions self._parse() - if self.group_by: + if self.group_by_custom_querysets: + self.main_queryset = [ + {"__index__": i} for i, v in enumerate(self.group_by_custom_querysets) + ] + elif self.group_by: 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 @@ -384,12 +403,15 @@ def _apply_queryset_options(self, query, fields=None): return query.values(*fields) return query.values() - def _construct_crosstab_filter(self, col_data): + def _construct_crosstab_filter(self, col_data, queryset_filters=None): """ In charge of adding the needed crosstab filter, specific to the case of is_remainder or not :param col_data: :return: """ + if queryset_filters: + return queryset_filters[0], queryset_filters[1] + if "__" in col_data["crosstab_field"]: column_name = col_data["crosstab_field"] else: @@ -397,11 +419,11 @@ def _construct_crosstab_filter(self, col_data): col_data["crosstab_field"], self.report_model ) column_name = field.column - if col_data["is_remainder"]: + if col_data["is_remainder"] and not queryset_filters: filters = [~Q(**{f"{column_name}__in": self.crosstab_ids})] else: filters = [Q(**{f"{column_name}": col_data["id"]})] - return filters + return filters, {} def _prepare_report_dependencies(self): from .fields import SlickReportField @@ -456,24 +478,33 @@ def _prepare_report_dependencies(self): report_model=self.report_model, date_field=self.date_field, queryset=self.queryset, + group_by_custom_querysets=self.group_by_custom_querysets, ) q_filters = None date_filter = { - f"{self.date_field}__gte": col_data.get( + f"{self.start_date_field_name}__gte": col_data.get( "start_date", self.start_date ), - f"{self.date_field}__lt": col_data.get("end_date", self.end_date), + f"{self.end_date_field_name}__lt": col_data.get( + "end_date", self.end_date + ), } date_filter.update(self.kwargs_filters) - if window == "crosstab": - q_filters = self._construct_crosstab_filter(col_data) + if ( + window == "crosstab" + or col_data.get("computation_flag", "") == "crosstab" + ): + q_filters, kw_filters = col_data["queryset_filters"] + date_filter.update(kw_filters) report_class.init_preparation(q_filters, date_filter) self.report_fields_classes[name] = report_class - @staticmethod - def get_primary_key_name(model): + # @staticmethod + def get_primary_key_name(self, model): + if self.group_by_custom_querysets: + return "__index__" for field in model._meta.fields: if field.primary_key: return field.attname @@ -489,7 +520,10 @@ def _get_record_data(self, obj, columns): data = {} group_by_val = None - if self.group_by: + if self.group_by_custom_querysets: + group_by_val = str(obj["__index__"]) + + elif self.group_by: if self.group_by_field.related_model and "__" not in self.group_by: primary_key_name = self.get_primary_key_name( self.group_by_field.related_model @@ -510,9 +544,11 @@ def _get_record_data(self, obj, columns): data[name] = col_data["ref"](obj, data) elif ( - col_data.get("source", "") == "magic_field" and self.group_by + col_data.get("source", "") == "magic_field" + and (self.group_by or self.group_by_custom_querysets) ) or (self.time_series_pattern and not self.group_by): source = self._report_fields_dependencies[window].get(name, False) + if source: computation_class = self.report_fields_classes[source] value = computation_class.get_dependency_value( @@ -559,7 +595,14 @@ def _default_format_row(self, row_obj): return row_obj @classmethod - def check_columns(cls, columns, group_by, report_model, container_class=None): + def check_columns( + cls, + columns, + group_by, + report_model, + container_class=None, + group_by_custom_querysets=None, + ): """ Check and parse the columns, throw errors in case an item in the columns cant not identified :param columns: List of columns @@ -569,6 +612,10 @@ def check_columns(cls, columns, group_by, report_model, container_class=None): :return: List of dict, each dict contains relevant data to the respective field in `columns` """ group_by_model = None + if group_by_custom_querysets: + if "__index__" not in columns: + columns.insert(0, "__index__") + if group_by: try: group_by_field = [ @@ -637,6 +684,19 @@ def check_columns(cls, columns, group_by, report_model, container_class=None): } else: # A database field + if group_by_custom_querysets and col == "__index__": + # group by custom queryset special case: which is the index + col_data = { + "name": col, + "verbose_name": cls.group_by_custom_querysets_column_verbose_name, + "source": "database", + "ref": "", + "type": "text", + } + col_data.update(options) + parsed_columns.append(col_data) + continue + model_to_use = ( group_by_model if group_by and "__" not in group_by @@ -678,11 +738,15 @@ def check_columns(cls, columns, group_by, report_model, container_class=None): def _parse(self): self.parsed_columns = self.check_columns( - self.columns, self.group_by, self.report_model, self.container_class + self.columns, + self.group_by, + self.report_model, + self.container_class, + self.group_by_custom_querysets, ) self._parsed_columns = list(self.parsed_columns) - self._time_series_parsed_columns = self.get_time_series_parsed_columns() self._crosstab_parsed_columns = self.get_crosstab_parsed_columns() + self._time_series_parsed_columns = self.get_time_series_parsed_columns() def get_database_columns(self): return [ @@ -751,6 +815,18 @@ def get_time_series_parsed_columns(self): "is_summable": magic_field_class.is_summable, } ) + + # append the crosstab fields, if they exist, on the time_series + if self._crosstab_parsed_columns: + for parsed_col in self._crosstab_parsed_columns: + parsed_col = parsed_col.copy() + parsed_col["name"] = ( + parsed_col["name"] + "TS" + dt[1].strftime("%Y%m%d") + ) + parsed_col["start_date"] = dt[0] + parsed_col["end_date"] = dt[1] + _values.append(parsed_col) + return _values def get_time_series_field_verbose_name( @@ -823,12 +899,20 @@ def get_crosstab_parsed_columns(self): :return: """ report_columns = self.crosstab_columns or [] - ids = list(self.crosstab_ids) - if self.crosstab_compute_remainder: + + ids = list(self.crosstab_ids) or list(self.crosstab_ids_custom_filters) + if self.crosstab_compute_remainder and not self.crosstab_ids_custom_filters: ids.append("----") output_cols = [] + ids_length = len(ids) - 1 - for counter, id in enumerate(ids): + for counter, crosstab_id in enumerate(ids): + queryset_filters = None + + if self.crosstab_ids_custom_filters: + queryset_filters = crosstab_id + crosstab_id = counter + for col in report_columns: magic_field_class = None if type(col) is str: @@ -836,24 +920,28 @@ def get_crosstab_parsed_columns(self): elif issubclass(col, SlickReportField): magic_field_class = col - output_cols.append( - { - "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_field, id - ), - "ref": magic_field_class, - "id": id, - "crosstab_field": self.crosstab_field, - "is_remainder": counter == ids_length - if self.crosstab_compute_remainder - else False, - "source": "magic_field" if magic_field_class else "", - "is_summable": magic_field_class.is_summable, - } + crosstab_column = { + "name": f"{magic_field_class.name}CT{crosstab_id}", + "original_name": magic_field_class.name, + "verbose_name": self.get_crosstab_field_verbose_name( + magic_field_class, self.crosstab_field, crosstab_id + ), + "ref": magic_field_class, + "id": crosstab_id, + "crosstab_field": self.crosstab_field, + "is_remainder": counter == ids_length + if self.crosstab_compute_remainder + else False, + "source": "magic_field" if magic_field_class else "", + "is_summable": magic_field_class.is_summable, + "computation_flag": "crosstab", # a flag, todo find a better way probably + } + crosstab_column["queryset_filters"] = self._construct_crosstab_filter( + crosstab_column, queryset_filters ) + output_cols.append(crosstab_column) + return output_cols def get_crosstab_field_verbose_name(self, computation_class, model, id): diff --git a/slick_reporting/templates/slick_reporting/base.html b/slick_reporting/templates/slick_reporting/base.html index f97f982..5a65ee7 100644 --- a/slick_reporting/templates/slick_reporting/base.html +++ b/slick_reporting/templates/slick_reporting/base.html @@ -22,18 +22,6 @@ {% endblock %} - -{##} -{##} - -{#Date picker #} -{##} -{##} -{##} {% include "slick_reporting/js_resources.html" %} diff --git a/slick_reporting/views.py b/slick_reporting/views.py index 9c9ddbf..8e846d9 100644 --- a/slick_reporting/views.py +++ b/slick_reporting/views.py @@ -421,6 +421,7 @@ def __init_subclass__(cls) -> None: cls.group_by, cls.get_report_model(), container_class=cls, + group_by_custom_querysets=cls.group_by_custom_querysets, ) super().__init_subclass__() diff --git a/tests/report_generators.py b/tests/report_generators.py index dd20ad7..ba768f0 100644 --- a/tests/report_generators.py +++ b/tests/report_generators.py @@ -51,6 +51,21 @@ class CrosstabOnClient(GenericGenerator): ] +class CrosstabTimeSeries(GenericGenerator): + group_by = "product" + columns = ["name", "__total_quantity__"] + # crosstab_field = "client" + # crosstab_columns = [ + # SlickReportField.create( + # Sum, "quantity", name="value__sum", verbose_name=_("Sales") + # ) + # ] + # crosstab_compute_remainder = False + + # time_series_pattern = "monthly" + # time_series_columns = ["__total_quantity__"] + + class CrosstabOnField(ReportGenerator): report_model = ComplexSales date_field = "doc_date" @@ -58,7 +73,6 @@ class CrosstabOnField(ReportGenerator): group_by = "product" columns = ["name"] crosstab_field = "flag" - crosstab_field = "flag" crosstab_ids = ["sales", "sales-return"] crosstab_columns = [ @@ -68,6 +82,27 @@ class CrosstabOnField(ReportGenerator): ] +class CrosstabCustomQueryset(ReportGenerator): + report_model = ComplexSales + date_field = "doc_date" + + group_by = "product" + columns = ["name"] + crosstab_field = "flag" + # crosstab_ids = ["sales", "sales-return"] + + crosstab_ids_custom_filters = [ + (None, dict(flag="sales")), + (None, dict(flag="sales-return")), + ] + + crosstab_columns = [ + SlickReportField.create( + Sum, "quantity", name="value__sum", verbose_name=_("Sales") + ) + ] + + class CrosstabOnTraversingField(ReportGenerator): report_model = ComplexSales date_field = "doc_date" diff --git a/tests/test_generator.py b/tests/test_generator.py index 3926409..3360ec9 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -7,7 +7,8 @@ from slick_reporting.fields import SlickReportField from slick_reporting.generator import ReportGenerator from slick_reporting.helpers import get_foreign_keys -from .models import OrderLine +from .models import OrderLine, ComplexSales +from django.utils.translation import gettext_lazy as _ from .report_generators import ( GeneratorWithAttrAsColumn, @@ -17,6 +18,8 @@ TimeSeriesCustomDates, CrosstabOnField, CrosstabOnTraversingField, + CrosstabTimeSeries, + CrosstabCustomQueryset, ) from .tests import BaseTestData, year @@ -86,6 +89,15 @@ def test_crosstab_on_field(self): self.assertEqual(data[0]["value__sumCT----"], 77, data) self.assertEqual(data[1]["value__sumCTsales-return"], 34, data) + def test_crosstab_ids_queryset(self): + # same test values as above, tests that crosstab_ids_custom_filters + report = CrosstabCustomQueryset() + data = report.get_report_data() + self.assertEqual(len(data), 2, data) + self.assertEqual(data[0]["value__sumCT0"], 90, data) + self.assertEqual(data[0]["value__sumCT1"], 30, data) + self.assertEqual(data[1]["value__sumCT1"], 34, data) + def test_crosstab_on_traversing_field(self): report = CrosstabOnTraversingField() data = report.get_report_data() @@ -95,6 +107,38 @@ def test_crosstab_on_traversing_field(self): self.assertEqual(data[0]["value__sumCT----"], 0, data) self.assertEqual(data[1]["value__sumCTOTHER"], 34, data) + def test_crosstab_time_series(self): + report = ReportGenerator( + report_model=ComplexSales, + date_field="doc_date", + group_by="product", + columns=["name", "__total_quantity__"], + time_series_pattern="monthly", + crosstab_field="client", + crosstab_columns=[ + SlickReportField.create( + Sum, "quantity", name="value__sum", verbose_name=_("Sales") + ) + ], + crosstab_ids=[self.client2.pk, self.client3.pk], + crosstab_compute_remainder=False, + ) + columns = report.get_list_display_columns() + time_series_columns = report.get_time_series_parsed_columns() + expected_num_of_columns = ( + 2 * datetime.today().month + ) # 2 client + 1 remainder * months since start of year + + self.assertEqual(len(time_series_columns), expected_num_of_columns, columns) + data = report.get_report_data() + self.assertEqual(data[0]["__total_quantity__"], 197, data) + sum_o_product_1 = 0 + for col in data[0]: + if col.startswith("value__") and "TS" in col: + sum_o_product_1 += data[0][col] + + self.assertEqual(sum_o_product_1, 197, data) + class GeneratorReportStructureTest(BaseTestData, TestCase): @classmethod @@ -326,6 +370,29 @@ def test_group_by_and_foreign_key_field(self): self.assertEqual(data[1]["contact__address"], "Street 2") self.assertEqual(data[2]["contact__address"], "Street 3") + def test_custom_group_by(self): + report = ReportGenerator( + report_model=SimpleSales, + group_by_custom_querysets=[ + SimpleSales.objects.filter( + client_id__in=[self.client1.pk, self.client2.pk] + ), + SimpleSales.objects.filter(client_id__in=[self.client3.pk]), + ], + columns=[ + # "__index__", is added automatically + SlickReportField.create(Sum, "value"), + "__total__", + ], + date_field="doc_date", + ) + + data = report.get_report_data() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["sum__value"], 900) + self.assertEqual(data[1]["sum__value"], 1200) + self.assertIn("__index__", data[0].keys()) + def test_traversing_group_by_and_foreign_key_field(self): report = ReportGenerator( report_model=SimpleSales,