From ee6b9fb1ddb188c4c24e434c98a7c980a8d3e3d1 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Thu, 8 Jun 2023 00:26:12 +0300 Subject: [PATCH 01/14] group by custom querysets --- slick_reporting/fields.py | 23 ++++++++++-- slick_reporting/generator.py | 70 +++++++++++++++++++++++++++++++----- slick_reporting/views.py | 1 + tests/test_generator.py | 23 ++++++++++++ 4 files changed, 105 insertions(+), 12 deletions(-) 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..ff163ca 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_verbose_name = "" + columns = None """A list of column names. Columns names can be @@ -152,6 +156,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, @@ -257,6 +262,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 = ( @@ -322,8 +331,13 @@ def __init__( # 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 @@ -456,14 +470,17 @@ 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": @@ -472,8 +489,10 @@ def _prepare_report_dependencies(self): 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 +508,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,7 +532,8 @@ 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: @@ -559,7 +582,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 +599,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 +671,20 @@ def check_columns(cls, columns, group_by, report_model, container_class=None): } else: # A database field + # breakpoint() + 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_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,7 +726,11 @@ 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() 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/test_generator.py b/tests/test_generator.py index 3926409..9cf8247 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -326,6 +326,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, From 4a084d13af443b4f56a3fac03082288b6fa5da92 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 10:19:46 +0300 Subject: [PATCH 02/14] Adding docs for custom group by --- docs/source/group_by_report.rst | 29 +++++++++++++++++++++++++++++ slick_reporting/generator.py | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/source/group_by_report.rst b/docs/source/group_by_report.rst index dd1556e..22c78f1 100644 --- a/docs/source/group_by_report.rst +++ b/docs/source/group_by_report.rst @@ -49,3 +49,32 @@ A Sample group by report would look like this: :align: center +Custom Group By querysets +------------------------- + +Grouping do not have to be over a specific field in the database , it can be over a queryset. + +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. + diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index ff163ca..266df0f 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -65,7 +65,7 @@ class ReportGeneratorAPI: group_by_custom_querysets = None """A List of querysets representing different group by options""" - group_by_custom_querysets_verbose_name = "" + group_by_custom_querysets_column_verbose_name = "" columns = None """A list of column names. @@ -676,7 +676,7 @@ def check_columns( # group by custom queryset special case: which is the index col_data = { "name": col, - "verbose_name": cls.group_by_custom_querysets_verbose_name, + "verbose_name": cls.group_by_custom_querysets_column_verbose_name, "source": "database", "ref": "", "type": "text", From 3856cc0f8f73e10770f1d215e74864b66afe34b8 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 11:46:03 +0300 Subject: [PATCH 03/14] Add crosstab fields to time_series --- slick_reporting/generator.py | 26 ++++++++++++++++++++------ tests/report_generators.py | 16 +++++++++++++++- tests/test_generator.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index 266df0f..38279ec 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -324,10 +324,8 @@ 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() @@ -483,7 +481,10 @@ def _prepare_report_dependencies(self): ), } date_filter.update(self.kwargs_filters) - if window == "crosstab": + if ( + window == "crosstab" + or col_data.get("computation_flag", "") == "crosstab" + ): q_filters = self._construct_crosstab_filter(col_data) report_class.init_preparation(q_filters, date_filter) @@ -536,6 +537,7 @@ def _get_record_data(self, obj, columns): 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( @@ -671,7 +673,6 @@ def check_columns( } else: # A database field - # breakpoint() if group_by_custom_querysets and col == "__index__": # group by custom queryset special case: which is the index col_data = { @@ -733,8 +734,8 @@ def _parse(self): 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 [ @@ -803,6 +804,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( @@ -903,6 +916,7 @@ def get_crosstab_parsed_columns(self): 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 } ) diff --git a/tests/report_generators.py b/tests/report_generators.py index dd20ad7..34aeb07 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 = [ diff --git a/tests/test_generator.py b/tests/test_generator.py index 9cf8247..789a248 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,7 @@ TimeSeriesCustomDates, CrosstabOnField, CrosstabOnTraversingField, + CrosstabTimeSeries, ) from .tests import BaseTestData, year @@ -95,6 +97,33 @@ 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() + data = report.get_report_data() + + self.assertEqual(len(columns), 3, columns) + + report = CrosstabOnClient( + crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True + ) + columns = report.get_list_display_columns() + self.assertEqual(len(columns), 4, columns) + class GeneratorReportStructureTest(BaseTestData, TestCase): @classmethod From 6a43d1ce807298f405766b2e1fec0e85e86cdf59 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 13:49:28 +0300 Subject: [PATCH 04/14] Add test for crosstab fields in time_series --- tests/test_generator.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_generator.py b/tests/test_generator.py index 789a248..de2ed06 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -114,15 +114,20 @@ def test_crosstab_time_series(self): crosstab_compute_remainder=False, ) columns = report.get_list_display_columns() - data = report.get_report_data() + 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(columns), 3, columns) + 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] - report = CrosstabOnClient( - crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True - ) - columns = report.get_list_display_columns() - self.assertEqual(len(columns), 4, columns) + self.assertEqual(sum_o_product_1, 197, data) class GeneratorReportStructureTest(BaseTestData, TestCase): From 95cc03dc7f59bc5d713f624943dd3fa9f35e287f Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 17:45:04 +0300 Subject: [PATCH 05/14] Add crosstab_ids_custom_filters --- CHANGELOG.md | 5 +++ docs/source/crosstab_options.rst | 39 +++++++++++++++++- slick_reporting/generator.py | 70 +++++++++++++++++++++----------- tests/report_generators.py | 21 ++++++++++ tests/test_generator.py | 10 +++++ 5 files changed, 120 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70582d..e5d40cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [1.0.0] + +- Added crosstab_ids_custom_filters to allow custom filters on crosstab ids +- + ## [0.9.0] - 2023-06-07 - Deprecated ``form_factory`` in favor of ``forms``, to be removed next version. diff --git a/docs/source/crosstab_options.rst b/docs/source/crosstab_options.rst index 1cbaaa4..6978b92 100644 --- a/docs/source/crosstab_options.rst +++ b/docs/source/crosstab_options.rst @@ -39,6 +39,43 @@ Here is a simple example of a crosstab report: # 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 + + + + 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 +135,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/slick_reporting/generator.py b/slick_reporting/generator.py index 38279ec..c8f4d40 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -127,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`) """ @@ -164,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, @@ -245,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 @@ -396,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: @@ -409,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 @@ -485,7 +495,8 @@ def _prepare_report_dependencies(self): window == "crosstab" or col_data.get("computation_flag", "") == "crosstab" ): - q_filters = self._construct_crosstab_filter(col_data) + 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 @@ -888,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: @@ -901,25 +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, - "computation_flag": "crosstab", # a flag, todo find a better way probably - } + 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/tests/report_generators.py b/tests/report_generators.py index 34aeb07..ba768f0 100644 --- a/tests/report_generators.py +++ b/tests/report_generators.py @@ -82,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 de2ed06..3360ec9 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -19,6 +19,7 @@ CrosstabOnField, CrosstabOnTraversingField, CrosstabTimeSeries, + CrosstabCustomQueryset, ) from .tests import BaseTestData, year @@ -88,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() From 2d8b0d65af99488a0bd47040f808981b23b98979 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 17:53:10 +0300 Subject: [PATCH 06/14] enhance the dcumentation --- docs/source/group_by_report.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/group_by_report.rst b/docs/source/group_by_report.rst index 22c78f1..831e87c 100644 --- a/docs/source/group_by_report.rst +++ b/docs/source/group_by_report.rst @@ -52,7 +52,7 @@ A Sample group by report would look like this: Custom Group By querysets ------------------------- -Grouping do not have to be over a specific field in the database , it can be over a queryset. +Grouping can also be over a curated queryset(s) Example: @@ -77,4 +77,6 @@ This report will create two groups, one for pending sales and another for paid a 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 From ce146e58ff6c84783a1d13441aab219711740fca Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 18:02:26 +0300 Subject: [PATCH 07/14] enhance the documentation --- docs/source/crosstab_options.rst | 4 ++++ docs/source/time_series_options.rst | 3 +++ docs/source/view_options.rst | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/source/crosstab_options.rst b/docs/source/crosstab_options.rst index 6978b92..600519d 100644 --- a/docs/source/crosstab_options.rst +++ b/docs/source/crosstab_options.rst @@ -75,6 +75,10 @@ Example: +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 ---------------------------------------------------- diff --git a/docs/source/time_series_options.rst b/docs/source/time_series_options.rst index bb07467..4971837 100644 --- a/docs/source/time_series_options.rst +++ b/docs/source/time_series_options.rst @@ -1,3 +1,5 @@ +.. _time_series: + Time Series Reports ================== A Time series report is a report that is generated for a periods of time. @@ -52,6 +54,7 @@ Here is a quick recipe to what you want to do time_series_selector_allow_empty = False # Allow the user to select an empty time series +.. time_series_options: Time Series Options ------------------- diff --git a/docs/source/view_options.rst b/docs/source/view_options.rst index a7fd448..5af41ee 100644 --- a/docs/source/view_options.rst +++ b/docs/source/view_options.rst @@ -5,7 +5,7 @@ We can categorize the output of a report into 4 sections: #. 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. +#. 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 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. #. 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. From 7edc886bf78d6073f7d91b179ce780ee7dde4029 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 18:04:49 +0300 Subject: [PATCH 08/14] CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d40cf..25e027b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file. ## [1.0.0] - 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 ## [0.9.0] - 2023-06-07 From 324ffca88e64661772bc39a4f812edc21b9f41f0 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Fri, 9 Jun 2023 19:56:20 +0300 Subject: [PATCH 09/14] Documentation --- docs/source/concept.rst | 27 ++++-------------- .../{search_form.rst => filter_form.rst} | 4 +-- docs/source/group_by_report.rst | 27 ++++++++++-------- docs/source/index.rst | 2 +- docs/source/the_view.rst | 28 +++++++++++++++---- docs/source/view_options.rst | 10 ++----- 6 files changed, 49 insertions(+), 49 deletions(-) rename docs/source/{search_form.rst => filter_form.rst} (98%) diff --git a/docs/source/concept.rst b/docs/source/concept.rst index ac80015..760907b 100644 --- a/docs/source/concept.rst +++ b/docs/source/concept.rst @@ -11,34 +11,19 @@ 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. :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. -2. Generator: The heart of the reporting engine , It's responsible for computing and generating the data and provides low level access. +2. :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. -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. +3. :ref:`Report View : A wrapper around the generator exposing the generator API in a ``FormView`` subclass that you can hook straight to your urls. + It provide a :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. 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 - - Settings -------- diff --git a/docs/source/search_form.rst b/docs/source/filter_form.rst similarity index 98% rename from docs/source/search_form.rst rename to docs/source/filter_form.rst index 3ce76dc..2a75163 100644 --- a/docs/source/search_form.rst +++ b/docs/source/filter_form.rst @@ -6,8 +6,8 @@ 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 ? ------------------------------------ +Customizing 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. diff --git a/docs/source/group_by_report.rst b/docs/source/group_by_report.rst index 831e87c..1807632 100644 --- a/docs/source/group_by_report.rst +++ b/docs/source/group_by_report.rst @@ -2,7 +2,11 @@ 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. +General +------- + +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: @@ -18,13 +22,20 @@ Example: 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 -Group by can also be a traversing field +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 +--------------------------- -.. 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. +``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: @@ -41,12 +52,6 @@ Example: 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 Custom Group By querysets diff --git a/docs/source/index.rst b/docs/source/index.rst index 353d868..1e6540b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -84,7 +84,7 @@ Next step :ref:`structure` time_series_options crosstab_options list_report_options - search_form + filter_form charts report_generator computation_field diff --git a/docs/source/the_view.rst b/docs/source/the_view.rst index 453f640..a095b56 100644 --- a/docs/source/the_view.rst +++ b/docs/source/the_view.rst @@ -3,20 +3,36 @@ The Slick Report View ===================== +Types of reports +---------------- +We can categorize the output of a report into 4 sections: + +#. 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 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. +#. 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. + + What is ReportView? -------------------- -ReportView is a CBV that inherits form ``FromView`` and expose the report generator needed attributes. -It: - -* Auto generate the search form based on the report model (Or you can create you own) +ReportView is a ``FromView`` subclass that exposes the report generator API allowing you to create a report in view. +It also +* Auto generate the filter form based on the report model * 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 - -Export to CSV +How to use it? -------------- +You can import it from ``django_slick_reporting.views`` +``from django_slick_reporting.views import ReportView`` + +In the next section we will go over the options and methods available on the ReportView class in regard to each of the types of reports listed above. + + +Exporting 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` diff --git a/docs/source/view_options.rst b/docs/source/view_options.rst index 5af41ee..f298fd1 100644 --- a/docs/source/view_options.rst +++ b/docs/source/view_options.rst @@ -1,19 +1,13 @@ Report View Options =================== -We can categorize the output of a report into 4 sections: - -#. 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 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. -#. 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 ----------------------- +General Options +--------------- .. attribute:: ReportView.report_model From 8cef97181e52502339ef2dd5dfd4f5c6b74a484c Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Sat, 10 Jun 2023 17:18:23 +0300 Subject: [PATCH 10/14] Documentation --- README.rst | 102 +++++++++---- docs/source/charts.rst | 100 +++++++++++++ docs/source/concept.rst | 27 +++- docs/source/exporting.rst | 16 ++ docs/source/index.rst | 66 ++++---- .../{ => report_view}/_static/crosstab.png | Bin .../_static/group_report.png | Bin .../_static/list_view_form.png | Bin .../source/report_view/_static/timeseries.png | Bin 0 -> 90369 bytes .../{ => report_view}/crosstab_options.rst | 9 +- docs/source/{ => report_view}/filter_form.rst | 10 +- .../{ => report_view}/group_by_report.rst | 8 +- docs/source/report_view/index.rst | 31 ++++ .../{ => report_view}/list_report_options.rst | 9 +- .../{ => report_view}/time_series_options.rst | 17 +-- .../source/{ => report_view}/view_options.rst | 6 +- docs/source/the_view.rst | 141 ------------------ 17 files changed, 294 insertions(+), 248 deletions(-) create mode 100644 docs/source/exporting.rst rename docs/source/{ => report_view}/_static/crosstab.png (100%) rename docs/source/{ => report_view}/_static/group_report.png (100%) rename docs/source/{ => report_view}/_static/list_view_form.png (100%) create mode 100644 docs/source/report_view/_static/timeseries.png rename docs/source/{ => report_view}/crosstab_options.rst (95%) rename docs/source/{ => report_view}/filter_form.rst (94%) rename docs/source/{ => report_view}/group_by_report.rst (95%) create mode 100644 docs/source/report_view/index.rst rename docs/source/{ => report_view}/list_report_options.rst (68%) rename docs/source/{ => report_view}/time_series_options.rst (90%) rename docs/source/{ => report_view}/view_options.rst (99%) delete mode 100644 docs/source/the_view.rst diff --git a/README.rst b/README.rst index 7c06eb0..5b067d1 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ 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 - Optimized for speed. -- Batteries included! Highcharts & Chart.js charting capabilities , DataTable.net & easily customizable Bootstrap form. +- Batteries included! Highcharts & Chart.js charting capabilities , DataTable.net & a Bootstrap form. all easily customizable and plugable. Installation ------------ @@ -56,7 +56,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 +73,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 +107,68 @@ 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, + ), + ] + + +.. 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): -This would return a table looking something like this: + crosstab_field = "client" + crosstab_ids = [ 1, 2, 3 ] + crosstab_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Value for")), + ] + crosstab_compute_remainder = True -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ -| 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 | -+--------------+----------------------+-----------------+----------------+-----------------------+-------------------------------+ + columns = [ + "some_optional_field", + # You can customize where the crosstab columns are displayed in relation to the other columns + "__crosstab__", -*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.* + # 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")), + ] --- -**On a low level** + .. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/crosstab.png?raw=true + :alt: Homepage + :align: center + +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,16 @@ 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 +224,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..8563239 100644 --- a/docs/source/charts.rst +++ b/docs/source/charts.rst @@ -1,6 +1,9 @@ Charting --------- +Charts Configuration +--------------------- + 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. @@ -15,3 +18,100 @@ 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 760907b..95c104f 100644 --- a/docs/source/concept.rst +++ b/docs/source/concept.rst @@ -9,19 +9,30 @@ 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: +These are the main components of Django Slick Reporting -1. :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. +#. :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. -2. :ref:`Generator `: Responsible for generating report and orchestrating and calculating the computation fields values and mapping them to the results. +#. :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. -3. :ref:`Report View : A wrapper around the generator exposing the generator API in a ``FormView`` subclass that you can hook straight to your urls. - It provide a :ref:`Filter Form ` to filter the report on. - It mimics the Generator API interface, so knowing one is enough to work with the other. +#. :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 + + +Types of reports +---------------- +We can categorize the output of the reports in this package into 4 sections: + +#. 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. +#. Time series report: a step up from the grouped report, where the calculations are computed for each time period (day, week, month, etc). +#. Crosstab report: a report 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. +#. 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. -4. Charting JS helpers: Django slick Reporting comes with highcharts and Charts js helpers libraries to plot the data generated. diff --git a/docs/source/exporting.rst b/docs/source/exporting.rst new file mode 100644 index 0000000..9c63152 --- /dev/null +++ b/docs/source/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/index.rst b/docs/source/index.rst index 1e6540b..be638d5 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' - - # 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,14 +50,26 @@ 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", + ), ] + # in urls.py + from django.urls import path + from .views import MonthlyProductSales + +Demo site +---------- + +https://django-slick-reporting.com is a quick walk-though with live code examples + + + Next step :ref:`structure` .. toctree:: @@ -78,14 +77,9 @@ Next step :ref:`structure` :caption: Contents: concept - the_view - view_options - group_by_report - time_series_options - crosstab_options - list_report_options - filter_form + report_view/index charts + exporting report_generator computation_field diff --git a/docs/source/_static/crosstab.png b/docs/source/report_view/_static/crosstab.png similarity index 100% rename from docs/source/_static/crosstab.png rename to docs/source/report_view/_static/crosstab.png diff --git a/docs/source/_static/group_report.png b/docs/source/report_view/_static/group_report.png similarity index 100% rename from docs/source/_static/group_report.png rename to docs/source/report_view/_static/group_report.png diff --git a/docs/source/_static/list_view_form.png b/docs/source/report_view/_static/list_view_form.png similarity index 100% rename from docs/source/_static/list_view_form.png rename to docs/source/report_view/_static/list_view_form.png diff --git a/docs/source/report_view/_static/timeseries.png b/docs/source/report_view/_static/timeseries.png new file mode 100644 index 0000000000000000000000000000000000000000..43202bf5e12bb76db6a33ccaa0f5aa5f3c0ec123 GIT binary patch literal 90369 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 NAtEbW`9uHb{{Xm;)!P67 literal 0 HcmV?d00001 diff --git a/docs/source/crosstab_options.rst b/docs/source/report_view/crosstab_options.rst similarity index 95% rename from docs/source/crosstab_options.rst rename to docs/source/report_view/crosstab_options.rst index 600519d..4d0dd4e 100644 --- a/docs/source/crosstab_options.rst +++ b/docs/source/report_view/crosstab_options.rst @@ -13,7 +13,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 +21,7 @@ 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,7 +30,8 @@ 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")), diff --git a/docs/source/filter_form.rst b/docs/source/report_view/filter_form.rst similarity index 94% rename from docs/source/filter_form.rst rename to docs/source/report_view/filter_form.rst index 2a75163..628c177 100644 --- a/docs/source/filter_form.rst +++ b/docs/source/report_view/filter_form.rst @@ -1,7 +1,7 @@ .. _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. @@ -21,8 +21,8 @@ You can also override the form by providing a ``form_class`` attribute to the re .. _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. @@ -125,4 +125,4 @@ Example a full example of a custom form: 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/group_by_report.rst b/docs/source/report_view/group_by_report.rst similarity index 95% rename from docs/source/group_by_report.rst rename to docs/source/report_view/group_by_report.rst index 1807632..e1e8b8e 100644 --- a/docs/source/group_by_report.rst +++ b/docs/source/report_view/group_by_report.rst @@ -2,8 +2,8 @@ Group By Reports ================ -General -------- +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. @@ -54,10 +54,10 @@ Example: -Custom Group By querysets +Group by custom querysets ------------------------- -Grouping can also be over a curated queryset(s) +Grouping can also be over a curated queryset(s). Example: diff --git a/docs/source/report_view/index.rst b/docs/source/report_view/index.rst new file mode 100644 index 0000000..cc933c2 --- /dev/null +++ b/docs/source/report_view/index.rst @@ -0,0 +1,31 @@ +.. _report_view: + +The Report View +=============== + +What is ReportView? +-------------------- + +ReportView is a ``FromView`` subclass that exposes the report generator API allowing you to create a report in view. +It also + +* Auto generate the filter form based on the report model +* 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 + + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + :titlesonly: + + view_options + group_by_report + time_series_options + crosstab_options + list_report_options + filter_form + + diff --git a/docs/source/list_report_options.rst b/docs/source/report_view/list_report_options.rst similarity index 68% rename from docs/source/list_report_options.rst rename to docs/source/report_view/list_report_options.rst index 152ed03..da7914f 100644 --- a/docs/source/list_report_options.rst +++ b/docs/source/report_view/list_report_options.rst @@ -1,8 +1,9 @@ -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. + +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/time_series_options.rst b/docs/source/report_view/time_series_options.rst similarity index 90% rename from docs/source/time_series_options.rst rename to docs/source/report_view/time_series_options.rst index 4971837..4528d16 100644 --- a/docs/source/time_series_options.rst +++ b/docs/source/report_view/time_series_options.rst @@ -1,7 +1,8 @@ .. _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. @@ -31,30 +32,25 @@ Here is a quick recipe to what you want to do ] # 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: +.. _time_series_options: Time Series Options ------------------- @@ -74,6 +70,7 @@ Time Series Options a list of Calculation Field names which will be included in the series calculation. .. code-block:: python + class MyReport(ReportView): time_series_columns = [ @@ -87,7 +84,7 @@ Time Series Options Links to demo -------------- +'''''''''''''' -Time series Selector pattern Demo `Demo `_ -and here is the `Code on github `_ for the report. +Time series Selector pattern `Demo `_ +and the `Code on github `_ for it. diff --git a/docs/source/view_options.rst b/docs/source/report_view/view_options.rst similarity index 99% rename from docs/source/view_options.rst rename to docs/source/report_view/view_options.rst index f298fd1..a34391f 100644 --- a/docs/source/view_options.rst +++ b/docs/source/report_view/view_options.rst @@ -1,13 +1,11 @@ -Report View Options -=================== +Report View General Options +=========================== 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. -General Options ---------------- .. attribute:: ReportView.report_model diff --git a/docs/source/the_view.rst b/docs/source/the_view.rst deleted file mode 100644 index a095b56..0000000 --- a/docs/source/the_view.rst +++ /dev/null @@ -1,141 +0,0 @@ -.. _customization: - -The Slick Report View -===================== - -Types of reports ----------------- -We can categorize the output of a report into 4 sections: - -#. 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 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. -#. 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. - - -What is ReportView? --------------------- - -ReportView is a ``FromView`` subclass that exposes the report generator API allowing you to create a report in view. -It also -* Auto generate the filter form based on the report model -* 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 - -How to use it? --------------- -You can import it from ``django_slick_reporting.views`` -``from django_slick_reporting.views import ReportView`` - -In the next section we will go over the options and methods available on the ReportView class in regard to each of the types of reports listed above. - - -Exporting 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. - -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", - }, - ], - } - - From 26be1a98289b08e1391420619df4656d4b7ef81e Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Sun, 11 Jun 2023 10:53:16 +0300 Subject: [PATCH 11/14] Documentation --- .pre-commit-config.yaml | 4 +- README.rst | 56 ++-- docs/source/charts.rst | 3 - docs/source/howto.rst | 28 ++ docs/source/index.rst | 1 + docs/source/report_view/crosstab_options.rst | 38 +-- docs/source/report_view/group_by_report.rst | 35 +-- .../report_view/time_series_options.rst | 95 +++---- docs/source/report_view/view_options.rst | 37 ++- docs/source/tutorial.rst | 242 ++++++++++++++++++ 10 files changed, 411 insertions(+), 128 deletions(-) create mode 100644 docs/source/howto.rst create mode 100644 docs/source/tutorial.rst 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/README.rst b/README.rst index 5b067d1..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 & a Bootstrap form. all easily customizable and plugable. +- Easily extendable. Installation ------------ @@ -108,7 +107,9 @@ Time Series columns = ["name", "sku"] # Settings for creating time series report - time_series_pattern = "monthly" # or "yearly" , "weekly" , "daily" , others and custom patterns + time_series_pattern = ( + "monthly" # or "yearly" , "weekly" , "daily" , others and custom patterns + ) time_series_columns = [ SlickReportField.create( Sum, "value", verbose_name=_("Sales Value"), name="value" @@ -135,34 +136,33 @@ Cross Tab .. code-block:: python - # in views.py - from slick_reporting.views import ReportView - from slick_reporting.fields import SlickReportField - from .models import MySalesItems + # in views.py + from slick_reporting.views import ReportView + from slick_reporting.fields import SlickReportField + from .models import MySalesItems - class MyCrosstabReport(ReportView): - crosstab_field = "client" - crosstab_ids = [ 1, 2, 3 ] - crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value for")), - ] - crosstab_compute_remainder = True + class MyCrosstabReport(ReportView): - columns = [ - "some_optional_field", - # You can customize where the crosstab columns are displayed in relation to the other columns - "__crosstab__", + crosstab_field = "client" + crosstab_ids = [1, 2, 3] + crosstab_columns = [ + SlickReportField.create(Sum, "value", verbose_name=_("Value for")), + ] + crosstab_compute_remainder = True - # 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")), + 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 +.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/crosstab.png?raw=true + :alt: Homepage + :align: center Low level @@ -176,11 +176,13 @@ You can interact with the `ReportGenerator` using same syntax as used with the ` from slick_reporting.generator import ReportGenerator from .models import MySalesModel + 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__"] diff --git a/docs/source/charts.rst b/docs/source/charts.rst index 8563239..cbc07e5 100644 --- a/docs/source/charts.rst +++ b/docs/source/charts.rst @@ -34,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": [ { @@ -51,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. @@ -91,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.rst b/docs/source/howto.rst new file mode 100644 index 0000000..88e8acc --- /dev/null +++ b/docs/source/howto.rst @@ -0,0 +1,28 @@ +======= +How To +======= + +Customize the form +------------------ + + + +Work with tree data & Nested categories +--------------------------------------- + + + + +Use the report view in our own template +--------------------------------------- + + +Change the report structure in response to User input +----------------------------------------------------- + + +Create your own Chart Engine +----------------------------- + +Create a Custom ComputationField and reuse it +--------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index be638d5..2253ad6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -76,6 +76,7 @@ Next step :ref:`structure` :maxdepth: 2 :caption: Contents: + tutorial concept report_view/index charts diff --git a/docs/source/report_view/crosstab_options.rst b/docs/source/report_view/crosstab_options.rst index 4d0dd4e..c9ed9cf 100644 --- a/docs/source/report_view/crosstab_options.rst +++ b/docs/source/report_view/crosstab_options.rst @@ -21,7 +21,10 @@ Here is a simple example of a crosstab report: SlickReportField.create(Sum, "value", verbose_name=_("Value")), ] - 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. + 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"] @@ -49,28 +52,31 @@ Example: .. code-block:: python - from .models import MySales + from .models import MySales + class MyCrosstabReport(ReportView): - date_field = "date" - group_by = "product" - report_model = MySales + date_field = "date" + group_by = "product" + report_model = MySales - crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - ] + 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 . + 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 + # These settings has NO EFFECT if crosstab_ids_custom_filters is set + crosstab_field = "client" + crosstab_ids = [1, 2] + crosstab_compute_remainder = True diff --git a/docs/source/report_view/group_by_report.rst b/docs/source/report_view/group_by_report.rst index e1e8b8e..76ea60f 100644 --- a/docs/source/report_view/group_by_report.rst +++ b/docs/source/report_view/group_by_report.rst @@ -18,8 +18,10 @@ Example: group_by = "expense" columns = [ - "name", # name field on the expense model - SlickReportField.create(Sum, "value", verbose_name=_("Total Expenditure"), name="value"), + "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: @@ -45,10 +47,10 @@ Example: class ExpenseTotal(ReportView): report_model = ExpenseTransaction report_title = _("Expenses Daily") - group_by = "expense__expensecategory" # Note the traversing + group_by = "expense__expensecategory" # Note the traversing columns = [ - "name", # name field on the ExpenseCategory model + "name", # name field on the ExpenseCategory model SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), ] @@ -64,19 +66,18 @@ 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"), - ] + 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. diff --git a/docs/source/report_view/time_series_options.rst b/docs/source/report_view/time_series_options.rst index 4528d16..1c6a104 100644 --- a/docs/source/report_view/time_series_options.rst +++ b/docs/source/report_view/time_series_options.rst @@ -10,44 +10,50 @@ 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 + 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: @@ -73,11 +79,14 @@ Time Series Options 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) - - ] + 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 + ), + ] diff --git a/docs/source/report_view/view_options.rst b/docs/source/report_view/view_options.rst index a34391f..b4c77a3 100644 --- a/docs/source/report_view/view_options.rst +++ b/docs/source/report_view/view_options.rst @@ -26,16 +26,15 @@ 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"] Columns names can be @@ -48,17 +47,16 @@ 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 - MyTotalReportField, # A computation Field class - "__total__", # a computation field registered in the computation field registry - ] + ] @@ -71,12 +69,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 ] @@ -91,11 +88,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 @@ -129,7 +126,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 diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst new file mode 100644 index 0000000..46b1010 --- /dev/null +++ b/docs/source/tutorial.rst @@ -0,0 +1,242 @@ +========= +Tutorial +========= + + +Let' say we have a Sale Transaction model in your project and you want to create some reports about it. + +.. code-block:: python + + from django.db import models + + + class Client(models.Model): + name = models.CharField(_("Name"), max_length=255) + country = models.CharField(_("Country"), max_length=255) + + + 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) + + + +Like: + +#. Total sales value per each project during a certain period. +#. Total sales value per each product each month during a certain period. +#. How much was sold per each product each month per each client country +#. Display last 10 sales transactions + + + +You can create a report for each of these questions using the following code: + +.. 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"), + ] + + 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 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. + +Let's continue with the second question: + +.. 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", + ), + ] + +Pretty Cool yes ? + +Now let's continue with the third question: + +.. 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", + ), + ] + + +Now let's continue with the fourth question: + +.. 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", + ), + ] + +Recap +===== +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 +* ``group_by``: The field to be used to group the report by +* ``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 +* ``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 +* ``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 +* ``chart_settings``: The chart settings to be used in the report + From 682e54e80fa5cf053afa9ddc3263ce463c77c984 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Sun, 11 Jun 2023 11:22:36 +0300 Subject: [PATCH 12/14] Documentation --- docs/source/howto.rst | 103 +++++++++++++++++- docs/source/tutorial.rst | 2 + .../templates/slick_reporting/base.html | 12 -- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/docs/source/howto.rst b/docs/source/howto.rst index 88e8acc..0a7195d 100644 --- a/docs/source/howto.rst +++ b/docs/source/howto.rst @@ -5,16 +5,115 @@ How To 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. -Work with tree data & Nested categories ---------------------------------------- +#. 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 diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 46b1010..1a60d7b 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -44,6 +44,7 @@ Like: You can create a report for each of these questions using the following code: .. code-block:: python + # in views.py from django.db.models import Sum from slick_reporting.views import ReportView, Chart @@ -73,6 +74,7 @@ You can create a report for each of these questions using the following code: title_source="title", ), ] + Then in your urls.py add the following: .. code-block:: python 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" %} From 45ec03eadc273e03ff430d942edb89ee057de8a1 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Mon, 3 Jul 2023 13:13:44 +0200 Subject: [PATCH 13/14] Documentation WIP --- docs/source/charts.rst | 4 +- docs/source/concept.rst | 78 +++---- docs/source/howto/customize_frontend.rst | 114 +++++++++++ docs/source/{howto.rst => howto/index.rst} | 45 +++- docs/source/howto/override_filter_form.rst | 0 docs/source/index.rst | 20 +- docs/source/recipes.rst | 19 -- docs/source/{ => ref}/computation_field.rst | 0 docs/source/ref/index.rst | 16 ++ docs/source/{ => ref}/report_generator.rst | 0 docs/source/ref/settings.rst | 29 +++ .../{report_view => ref}/view_options.rst | 28 +-- docs/source/report_view/index.rst | 31 --- .../_static/crosstab.png | Bin .../_static/group_report.png | Bin .../_static/list_view_form.png | Bin .../_static/timeseries.png | Bin .../crosstab_options.rst | 2 + docs/source/{ => topics}/exporting.rst | 2 +- .../{report_view => topics}/filter_form.rst | 21 +- .../group_by_report.rst | 2 + docs/source/topics/index.rst | 36 ++++ .../list_report_options.rst | 2 + .../time_series_options.rst | 38 ++-- docs/source/tutorial.rst | 193 ++++++++++++++++-- 25 files changed, 511 insertions(+), 169 deletions(-) create mode 100644 docs/source/howto/customize_frontend.rst rename docs/source/{howto.rst => howto/index.rst} (87%) create mode 100644 docs/source/howto/override_filter_form.rst delete mode 100644 docs/source/recipes.rst rename docs/source/{ => ref}/computation_field.rst (100%) create mode 100644 docs/source/ref/index.rst rename docs/source/{ => ref}/report_generator.rst (100%) create mode 100644 docs/source/ref/settings.rst rename docs/source/{report_view => ref}/view_options.rst (93%) delete mode 100644 docs/source/report_view/index.rst rename docs/source/{report_view => topics}/_static/crosstab.png (100%) rename docs/source/{report_view => topics}/_static/group_report.png (100%) rename docs/source/{report_view => topics}/_static/list_view_form.png (100%) rename docs/source/{report_view => topics}/_static/timeseries.png (100%) rename docs/source/{report_view => topics}/crosstab_options.rst (99%) rename docs/source/{ => topics}/exporting.rst (98%) rename docs/source/{report_view => topics}/filter_form.rst (94%) rename docs/source/{report_view => topics}/group_by_report.rst (99%) create mode 100644 docs/source/topics/index.rst rename docs/source/{report_view => topics}/list_report_options.rst (97%) rename docs/source/{report_view => topics}/time_series_options.rst (84%) diff --git a/docs/source/charts.rst b/docs/source/charts.rst index cbc07e5..3dafcef 100644 --- a/docs/source/charts.rst +++ b/docs/source/charts.rst @@ -1,5 +1,5 @@ -Charting ---------- +Charting and Front End Customization +===================================== Charts Configuration --------------------- diff --git a/docs/source/concept.rst b/docs/source/concept.rst index 95c104f..33aa12f 100644 --- a/docs/source/concept.rst +++ b/docs/source/concept.rst @@ -1,65 +1,53 @@ .. _structure: -Structure -========== +How the documentation is organized +================================== + +: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). -If you haven't yet, please check https://django-slick-reporting.com for a quick walk-though with live code examples.. -And now, Let's explore the main components of Django Slick Reporting and what setting you can set on project level. +:ref:`How-to guides ` +----------------------------- -Components ----------- -These are the main components of Django Slick Reporting +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. -#. :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. -#. :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:`Topic Guides ` +---------------------------- -#. :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. +Discuss each type of reports you can create with Django Slick Reporting and their options. -#. 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 + * :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 ----------------- -We can categorize the output of the reports in this package into 4 sections: +:ref:`Reference ` +---------------------------- -#. 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. -#. Time series report: a step up from the grouped report, where the calculations are computed for each time period (day, week, month, etc). -#. Crosstab report: a report 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. -#. 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. +Detailed information about main on Django Slick Reporting's main components, such as the :ref:`Report View `, :ref:`Generator `, :ref:`Computation Field `, 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. + #. :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. -Settings --------- + #. 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 -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. +Demo site +--------- + +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/howto/customize_frontend.rst b/docs/source/howto/customize_frontend.rst new file mode 100644 index 0000000..3dafcef --- /dev/null +++ b/docs/source/howto/customize_frontend.rst @@ -0,0 +1,114 @@ +Charting and Front End Customization +===================================== + +Charts Configuration +--------------------- + +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 +--------------------------- + +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/howto.rst b/docs/source/howto/index.rst similarity index 87% rename from docs/source/howto.rst rename to docs/source/howto/index.rst index 0a7195d..6389ee6 100644 --- a/docs/source/howto.rst +++ b/docs/source/howto/index.rst @@ -1,9 +1,13 @@ +.. _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. @@ -125,3 +129,42 @@ 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 2253ad6..de1d877 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -35,7 +35,7 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene from .models import MySalesItems - class MonthlyProductSales(ReportView): + class ProductSales(ReportView): report_model = MySalesItems date_field = "date_placed" @@ -61,7 +61,11 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene # in urls.py from django.urls import path - from .views import MonthlyProductSales + from .views import ProductSales + + urlpatterns = [ + path("product-sales/", ProductSales.as_view(), name="product-sales"), + ] Demo site ---------- @@ -70,19 +74,19 @@ https://django-slick-reporting.com is a quick walk-though with live code example -Next step :ref:`structure` +Next step :ref:`tutorial` .. toctree:: :maxdepth: 2 :caption: Contents: - tutorial concept - report_view/index + tutorial + howto/index + topics/index charts - exporting - 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/report_view/view_options.rst b/docs/source/ref/view_options.rst similarity index 93% rename from docs/source/report_view/view_options.rst rename to docs/source/ref/view_options.rst index b4c77a3..a98ce92 100644 --- a/docs/source/report_view/view_options.rst +++ b/docs/source/ref/view_options.rst @@ -1,19 +1,20 @@ -Report View General Options -=========================== +.. _report_view_options: +General Options +================ -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. .. 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 @@ -37,9 +38,10 @@ Below is the general list of options that can be used to control the behavior of return record["first_name"] + " " + record["last_name"] - Columns names can be + Here is a list of all available column options available. A column 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 @@ -50,12 +52,14 @@ Below is the general list of options that can be used to control the behavior of class MyReport(ReportView): columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), # a computation field created on the fly - MyTotalReportField, + SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), + # A computation Field class - "__total__", + MyTotalReportField, + # a computation field registered in the computation field registry + "__total__", ] @@ -218,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/report_view/index.rst b/docs/source/report_view/index.rst deleted file mode 100644 index cc933c2..0000000 --- a/docs/source/report_view/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _report_view: - -The Report View -=============== - -What is ReportView? --------------------- - -ReportView is a ``FromView`` subclass that exposes the report generator API allowing you to create a report in view. -It also - -* Auto generate the filter form based on the report model -* 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 - - - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - :titlesonly: - - view_options - group_by_report - time_series_options - crosstab_options - list_report_options - filter_form - - diff --git a/docs/source/report_view/_static/crosstab.png b/docs/source/topics/_static/crosstab.png similarity index 100% rename from docs/source/report_view/_static/crosstab.png rename to docs/source/topics/_static/crosstab.png diff --git a/docs/source/report_view/_static/group_report.png b/docs/source/topics/_static/group_report.png similarity index 100% rename from docs/source/report_view/_static/group_report.png rename to docs/source/topics/_static/group_report.png diff --git a/docs/source/report_view/_static/list_view_form.png b/docs/source/topics/_static/list_view_form.png similarity index 100% rename from docs/source/report_view/_static/list_view_form.png rename to docs/source/topics/_static/list_view_form.png diff --git a/docs/source/report_view/_static/timeseries.png b/docs/source/topics/_static/timeseries.png similarity index 100% rename from docs/source/report_view/_static/timeseries.png rename to docs/source/topics/_static/timeseries.png diff --git a/docs/source/report_view/crosstab_options.rst b/docs/source/topics/crosstab_options.rst similarity index 99% rename from docs/source/report_view/crosstab_options.rst rename to docs/source/topics/crosstab_options.rst index c9ed9cf..7e6e26a 100644 --- a/docs/source/report_view/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. diff --git a/docs/source/exporting.rst b/docs/source/topics/exporting.rst similarity index 98% rename from docs/source/exporting.rst rename to docs/source/topics/exporting.rst index 9c63152..317b491 100644 --- a/docs/source/exporting.rst +++ b/docs/source/topics/exporting.rst @@ -1,5 +1,5 @@ Exporting ---------- +========= Exporting to CSV ----------------- diff --git a/docs/source/report_view/filter_form.rst b/docs/source/topics/filter_form.rst similarity index 94% rename from docs/source/report_view/filter_form.rst rename to docs/source/topics/filter_form.rst index 628c177..2e540ef 100644 --- a/docs/source/report_view/filter_form.rst +++ b/docs/source/topics/filter_form.rst @@ -6,20 +6,13 @@ Customizing Filter Form The filter form is a form that is used to filter the data to be used in the report. -Customizing the generated form ------------------------------- +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. - -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: +Changing the generated form API is still private, however, you can use your own form easily. Overriding the Form -------------------- @@ -120,8 +113,10 @@ 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 diff --git a/docs/source/report_view/group_by_report.rst b/docs/source/topics/group_by_report.rst similarity index 99% rename from docs/source/report_view/group_by_report.rst rename to docs/source/topics/group_by_report.rst index 76ea60f..88bdc45 100644 --- a/docs/source/report_view/group_by_report.rst +++ b/docs/source/topics/group_by_report.rst @@ -1,3 +1,5 @@ +.. _group_by_topic: + ================ Group By Reports ================ 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/report_view/list_report_options.rst b/docs/source/topics/list_report_options.rst similarity index 97% rename from docs/source/report_view/list_report_options.rst rename to docs/source/topics/list_report_options.rst index da7914f..f424f23 100644 --- a/docs/source/report_view/list_report_options.rst +++ b/docs/source/topics/list_report_options.rst @@ -1,3 +1,5 @@ +.. _list_reports: + List Reports ============ diff --git a/docs/source/report_view/time_series_options.rst b/docs/source/topics/time_series_options.rst similarity index 84% rename from docs/source/report_view/time_series_options.rst rename to docs/source/topics/time_series_options.rst index 1c6a104..e54c6fb 100644 --- a/docs/source/report_view/time_series_options.rst +++ b/docs/source/topics/time_series_options.rst @@ -17,30 +17,36 @@ Here is a quick recipe to what you want to do class MyReport(ReportView): - time_series_pattern = "monthly" # 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)), - # ] + 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")), ] - # These columns will be calculated for each period in the time series. + columns = [ - "some_optional_field", - "__time_series__", + "product_sku", + # You can customize where the time series columns are displayed in relation to the other columns - SlickReportField.create(Sum, "value", verbose_name=_("Value")), + "__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")), + ] - time_series_selector = True + # This will display a selector to change the time series pattern + time_series_selector = True # settings for the time series selector # ---------------------------------- @@ -48,12 +54,10 @@ Here is a quick recipe to what you want to do 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 - ) + # 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: diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1a60d7b..dc4da18 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -1,18 +1,22 @@ +.. _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 we have a Sale Transaction model in your project and you want to create some reports about it. +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) + country = models.CharField(_("Country"), max_length=255, default="US") class Product(models.Model): @@ -32,20 +36,33 @@ Let' say we have a Sale Transaction model in your project and you want to create -Like: +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 +================ -#. Total sales value per each project during a certain period. -#. Total sales value per each product each month during a certain period. -#. How much was sold per each product each month per each client country -#. Display last 10 sales transactions +1. Total sales per product +-------------------------- +This can be done via an SQL statement looking like this: +.. code-block:: sql -You can create a report for each of these questions using the following code: + 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 @@ -59,11 +76,8 @@ You can create a report for each of these questions using the following code: group_by = "product" columns = [ "title", - ( - SlickReportField.create(Sum, "quantity"), - {"verbose_name": "Total Quantity sold", "is_summable": False}, - ), - SlickReportField.create(Sum, "value", name="sum__value"), + 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 = [ @@ -88,13 +102,50 @@ Then in your urls.py add the following: ), ] -Now visit the url ``/total-product-sales/`` and you will see the report. Containing a Filter Form, the report table and a chart. +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. -Let's continue with the second question: +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 @@ -143,9 +194,13 @@ then again in your urls.py add the following: ), ] +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 ? -Now let's continue with the third question: +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 @@ -186,7 +241,9 @@ Then again in your urls.py add the following: ] -Now let's continue with the fourth question: +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 @@ -224,21 +281,117 @@ Then again in your urls.py add the following: ), ] +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 -* ``group_by``: The field to be used to group the report by * ``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 -* ``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 * ``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. From dea2e75b4d27c35fdd8e11fadd93bada1ff303e4 Mon Sep 17 00:00:00 2001 From: Ramez Ashraf Date: Mon, 3 Jul 2023 13:45:44 +0200 Subject: [PATCH 14/14] version bump --- CHANGELOG.md | 3 ++- slick_reporting/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e027b..dbca0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ All notable changes to this project will be documented in this file. -## [1.0.0] +## [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 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"