diff --git a/CHANGELOG.md b/CHANGELOG.md index 177c64a..998c451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [1.2.0] - 2023-10-10 +- Add ``get_slick_reporting_media`` and ``get_charts_media`` templatetags +- Add `get_group_by_custom_querysets` hook to ReportView +- Enhance and document adding export options and customizing the builtin export to csv button +- Enhance and document adding custom buttons to the report page +- Enhance and document adding a new chart engine +- Fix in SlickReportingListView +- Move all css and js resources to be handled by `Media` governed by `settings.SLICK_REPORTING_SETTINGS` + + ## [1.1.1] - 2023-09-25 - Change settings to be a dict , adding support JQUERY_URL and FONT AWESOME customization #79 & #81 - Fix issue with chartjs not being loaded #80 diff --git a/demo_proj/demo_app/helpers.py b/demo_proj/demo_app/helpers.py index a639d04..d8271e5 100644 --- a/demo_proj/demo_app/helpers.py +++ b/demo_proj/demo_app/helpers.py @@ -39,7 +39,11 @@ ("crosstab-report-with-time-series", reports.CrossTabWithTimeSeries), ] OTHER = [ + ("highcharts-examples", reports.HighChartExample), ("chartjs-examples", reports.ChartJSExample), + ("apexcharts-examples", reports.ProductSalesApexChart), + ("custom-export", reports.CustomExportReport), + ("form-initial", reports.ReportWithFormInitial), ] diff --git a/demo_proj/demo_app/reports.py b/demo_proj/demo_app/reports.py index 30cf19d..cd843bd 100644 --- a/demo_proj/demo_app/reports.py +++ b/demo_proj/demo_app/reports.py @@ -1,6 +1,7 @@ import datetime from django.db.models import Sum, Q +from django.http import HttpResponse from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField @@ -170,9 +171,10 @@ class LastTenSales(ListReportView): report_model = SalesTransaction report_title = "Last 10 sales" date_field = "date" - filters = ["client"] + filters = ["product", "client", "date"] columns = [ - "product", + "product__name", + "client__name", "date", "quantity", "price", @@ -607,3 +609,92 @@ class ChartJSExample(TimeSeriesReport): # plot_total=True, ), ] + + +class HighChartExample(ChartJSExample): + chart_engine = "highcharts" + + +class ProductSalesApexChart(ReportView): + report_title = _("Product Sales Apex Charts") + report_model = SalesTransaction + date_field = "date" + group_by = "product" + chart_engine = "apexcharts" + template_name = "demo/apex_report.html" + + columns = [ + "name", + ComputationField.create( + method=Sum, + field="value", + name="value__sum", + verbose_name="Total sold $", + is_summable=True, + ), + ] + + # Charts + chart_settings = [ + Chart( + "Total sold $", + type="pie", + data_source=["value__sum"], + title_source=["name"], + ), + Chart( + "Total sold $", + type="bar", + data_source=["value__sum"], + title_source=["name"], + ), + Chart( + "A custom Entry Point $", + type="bar", + data_source=["value__sum"], + title_source=["name"], + entryPoint="displayChartCustomEntryPoint", + ), + ] + + +class CustomExportReport(GroupByReport): + report_title = _("Custom Export Report") + export_actions = ["export_pdf"] + + def export_pdf(self, report_data): + return HttpResponse(f"Dummy PDF Exported \n {report_data}") + + export_pdf.title = _("Export PDF") + export_pdf.css_class = "btn btn-secondary" + + def export_csv(self, report_data): + return super().export_csv(report_data) + + export_csv.title = _("My Custom CSV export Title") + export_csv.css_class = "btn btn-primary" + + +class ReportWithFormInitial(ReportView): + report_title = _("Report With Form Initial") + report_model = SalesTransaction + date_field = "date" + group_by = "product" + + columns = [ + "name", + ComputationField.create( + method=Sum, + field="value", + name="value__sum", + verbose_name="Total sold $", + is_summable=True, + ), + ] + + def get_initial(self): + from .models import Client + + initial = super().get_initial() + initial["client_id"] = [Client.objects.first().pk, Client.objects.last().pk] + return initial diff --git a/demo_proj/demo_proj/settings.py b/demo_proj/demo_proj/settings.py index a447da6..d5a987f 100644 --- a/demo_proj/demo_proj/settings.py +++ b/demo_proj/demo_proj/settings.py @@ -37,7 +37,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "demo_app", "crispy_forms", "crispy_bootstrap5", @@ -130,3 +129,13 @@ CRISPY_TEMPLATE_PACK = "bootstrap5" CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" + +SLICK_REPORTING_SETTINGS = { + "CHARTS": { + "apexcharts": { + "entryPoint": "DisplayApexPieChart", + "js": ("https://cdn.jsdelivr.net/npm/apexcharts", "slick_reporting/slick_reporting.chartsjs.js"), + "css": {"all": ("https://cdn.jsdelivr.net/npm/apexcharts/dist/apexcharts.min.css",)}, + }, + }, +} diff --git a/demo_proj/templates/dashboard.html b/demo_proj/templates/dashboard.html index 9a7999d..ed85e24 100644 --- a/demo_proj/templates/dashboard.html +++ b/demo_proj/templates/dashboard.html @@ -3,22 +3,7 @@ {% block page_title %} Dashboard {% endblock %} {% block meta_page_title %} Dashboard {% endblock %} -{% block extrajs %} - {% include "slick_reporting/js_resources.html" %} - {# make sure to have the js_resources added to the dashboard page #} - -{% endblock %} {% block content %}
@@ -51,4 +36,26 @@
+{% endblock %} + +{% block extrajs %} + {% include "slick_reporting/js_resources.html" %} + {# make sure to have the js_resources added to the dashboard page #} + + {% get_charts_media "all" %} + {# make sure to add all charts needed media, here the "all" arguments add all charts media to the page, #} + {# You can skip it and add needed media by hand #} + + + {% endblock %} \ No newline at end of file diff --git a/demo_proj/templates/demo/apex_report.html b/demo_proj/templates/demo/apex_report.html new file mode 100644 index 0000000..08d5bac --- /dev/null +++ b/demo_proj/templates/demo/apex_report.html @@ -0,0 +1,64 @@ +{% extends "slick_reporting/report.html" %} +{% load slick_reporting_tags %} + +{% block content %} + {{ block.super }} + +{% endblock %} + +{% block extrajs %} + {{ block.super }} + + + +{% endblock %} \ No newline at end of file diff --git a/demo_proj/templates/menu.html b/demo_proj/templates/menu.html index 561dabd..f961660 100644 --- a/demo_proj/templates/menu.html +++ b/demo_proj/templates/menu.html @@ -163,6 +163,21 @@
+ + + + + \ No newline at end of file diff --git a/docs/source/howto/customize_frontend.rst b/docs/source/howto/customize_frontend.rst index 3dafcef..0b49b18 100644 --- a/docs/source/howto/customize_frontend.rst +++ b/docs/source/howto/customize_frontend.rst @@ -1,23 +1,6 @@ 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 @@ -112,3 +95,26 @@ Let's have a look } +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 = { + "report_slug": "", # the report slug, defaults to the class name all lower + "data": [], # a list of objects representing the actual results of the report + "columns": [], # A list explaining the columns/keys in the data results. + # ie: len(response.columns) == len(response.data[i].keys()) + # A List of objects. each object contain field needed information like verbose name , if summable and hints about the data type. + "metadata": {}, # 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. + "chart_settings": [], # a list of objects mirror of the set charts_settings + } + + diff --git a/docs/source/howto/index.rst b/docs/source/howto/index.rst index 8f78571..099ec1e 100644 --- a/docs/source/howto/index.rst +++ b/docs/source/howto/index.rst @@ -31,6 +31,7 @@ The interface is simple, only 3 mandatory methods to implement, The rest are man # forms.py from slick_reporting.forms import BaseReportForm + class RequestFilterForm(BaseReportForm, forms.Form): SECURE_CHOICES = ( diff --git a/docs/source/ref/computation_field.rst b/docs/source/ref/computation_field.rst index 2d6ce38..56e8ab6 100644 --- a/docs/source/ref/computation_field.rst +++ b/docs/source/ref/computation_field.rst @@ -1,105 +1,4 @@ -.. _computation_field: - - -Computation Field API -===================== - -Responsible for preforming the calculation. - - -ReportField Basic Structure: ----------------------------- - -Earlier in he docs you saw the computation fields ``'__total__quantity__'`` -Let's see how it's written in `slick_reporting.fields` - - -.. code-block:: python - - from slick_reporting.fields import ComputationField - from slick_reporting.decorators import report_field_register - - - @report_field_register - class TotalQTYReportField(ComputationField): - - # The name to use when using this field in the generator - name = '__total_quantity__' - - # the field we want to compute on - calculation_field = 'quantity' - - # What method we want - calculation_method = Sum # the default - - # A verbose name - verbose name = 'Total quantity' - - -If you want AVG to the field `price` then the ReportField would look like this - -.. code-block:: python - - from django.db.models import Avg - - @report_field_register - class TotalQTYReportField(ComputationField): - - name = '__avg_price__' - calculation_field = 'price' - calculation_method = Avg - verbose name = 'Avg. Price' - - -How it works ? --------------- -The ReportGenerator is initialized with the needed configuration, -it generates a list of the needed fields to be displayed and computed. -For each computation field, it's given the filters needed and -asked to get all the results prepared. **The preparation is a duty of the ReportField anyway**, -then for each report_model record, the ReportGenerator again asks each ComputationField to get the data it has for each record and map it where it belongs. - - - -Bundled Report Fields ---------------------- - -* __total__ : Sum of the field names value -* __total_quantity__ :Sum of the field names 'quantity' -* __fb__ : Sum of the field value on the start date (or the start date of the active time series window) -* __balance__: Compound some of the field `value` . - -Difference between total and balance is: - -The field __total__ will return that client 1 bought 10 in Jan, 12 in Feb , 13 in March. while __balance__ will report -client compound buy: 10 in Jan, 22 in Feb and 35 in March - - -Registering Report Field ------------------------- - -To make this ReportField class available to the report, it has to be registered via ``report_field_register`` - - -Say you want to further customize your calculation, maybe you need to run a complex query - -You can override both of those method and control the calculation - -Calculation Flow: ------------------ - -ReportGenerator call - -1. prepare -2. resolve - - -Two side calculation --------------------- - -# todo: -# Document how a single field can be computed like a debit and credit. - +.. _computation_field_ref: ComputationField API -------------------- diff --git a/docs/source/ref/settings.rst b/docs/source/ref/settings.rst index f3036be..186a731 100644 --- a/docs/source/ref/settings.rst +++ b/docs/source/ref/settings.rst @@ -1,4 +1,5 @@ -.. _ settings: +.. _settings: + Settings ======== diff --git a/docs/source/ref/view_options.rst b/docs/source/ref/view_options.rst index d96a98d..be5940e 100644 --- a/docs/source/ref/view_options.rst +++ b/docs/source/ref/view_options.rst @@ -54,10 +54,8 @@ Below is the list of general options that is used across all types of reports. columns = [ # a computation field created on the fly ComputationField.create(Sum, "value", verbose_name=_("Value"), name="value"), - # A computation Field class MyTotalReportField, - # a computation field registered in the computation field registry "__total__", ] diff --git a/docs/source/topics/charts.rst b/docs/source/topics/charts.rst new file mode 100644 index 0000000..78f89aa --- /dev/null +++ b/docs/source/topics/charts.rst @@ -0,0 +1,141 @@ +Charts Customization +==================== + +Charts Configuration +--------------------- + +ReportView ``charts_settings`` is a list of objects which each object represent a chart configurations. +The chart configurations are: + +* title: the Chart title. Defaults to the `report_title`. +* type: A string. Examples are pie, bar, line, etc ... +* engine_name: A string, default to the ReportView ``chart_engine`` attribute, then to the ``SLICK_REPORTING_SETTINGS.DEFAULT_CHARTS_ENGINE``. +* data_source: string, the field name containing the numbers we want to plot. +* title_source: string, the field name containing labels of the data_source +* plot_total: if True the chart will plot the total of the columns. Useful with time series and crosstab reports. +* entryPoint: the javascript entry point to display the chart, the entryPoint function accepts the data, $elem and the chartSettings parameters. + +On front end, for each chart needed we pass the whole response to the relevant chart helper function and it handles the rest. + + + +Customizing the entryPoint for a chart +-------------------------------------- + +Sometimes you want to display the chart differently, in this case, you can just change the entryPoint function. + +Example: + +.. code-block:: python + + class ProductSalesApexChart(ReportView): + # .. + template_name = "product_sales_report.html" + chart_settings = [ + # .. + Chart( + "Total sold $", + type="bar", + data_source=["value__sum"], + title_source=["name"], + entryPoint="displayChartCustomEntryPoint", # this is the new entryPoint + ), + ] + + +Then in your template `product_sales_report.html` add the javascript function specified as the new entryPoint. + +.. code-block:: html+django + + {% extends "slick_reporting/report.html" %} + {% load slick_reporting_tags %} + {% block extra_js %} + {{ block.super }} + + + {% endblock %} + +Adding a new charting engine +---------------------------- + +In the following part we will add some Apex charts to the demo app to demonstrate how you can add your own charting engine to slick reporting. + +#. We need to add the new chart Engine to the settings. Note that the css and js are specified and handled like Django's ``Form.Media`` + +.. code-block:: python + + SLICK_REPORTING_SETTINGS = { + "CHARTS": { + "apexcharts": { + "entryPoint": "DisplayApexPieChart", + "js": ( + "https://cdn.jsdelivr.net/npm/apexcharts", + "js_file_for_apex_chart.js", # this file contains the entryPoint function and is responsible + # for compiling the data and rendering the chart + ), + "css": { + "all": "https://cdn.jsdelivr.net/npm/apexcharts/dist/apexcharts.min.css" + }, + } + }, + } + +#. Add the entry point function to the javascript file `js_file_for_apex_chart.js` in this example. + +It can look something like this: + +.. code-block:: javascript + + let chart = null; + function DisplayApexPieChart(data, $elem, chartOptions) { + // Where: + // data: is the ajax response coming from server + // $elem: is the jquery element where the chart should be rendered + // chartOptions: is the relevant chart dictionary/object in your ReportView chart_settings + + let legendAndSeries = $.slick_reporting.chartsjs.getGroupByLabelAndSeries(data, chartOptions); + // `getGroupByLabelAndSeries` is a helper function that will return an object with two keys: labels and series + + let options = {} + if (chartOptions.type === "pie") { + options = { + series: legendAndSeries.series, + chart: { + type: "pie", + height: 350 + }, + labels: legendAndSeries.labels, + }; + } else { + options = { + chart: { + type: 'bar' + }, + series: [{ + name: 'Sales', + data: legendAndSeries.series + }], + xaxis: { + categories: legendAndSeries.labels, + } + } + } + + try { + // destroy old chart, if any + chart.destroy(); + } catch (e) { + // do nothing + } + + chart = new ApexCharts($elem[0], options); + chart.render(); + } + diff --git a/docs/source/topics/computation_field.rst b/docs/source/topics/computation_field.rst new file mode 100644 index 0000000..ff20b7a --- /dev/null +++ b/docs/source/topics/computation_field.rst @@ -0,0 +1,184 @@ +.. _computation_field: + + +Computation Field +================= + +ComputationFields are the basic unit in a report.they represent a number that is being computed. + +Computation Fields can be add to a report as a class, as you saw in other examples , or by name. + + +Creating Computation Fields +--------------------------- + +There are 3 ways you can create a Computation Field + +1. Create a subclass of ComputationField and set the needed attributes and use it in the columns attribute of the ReportView +2. Use the `ComputationField.create()` method and pass the needed attributes and use it in the columns attribute of the ReportView +3. Use the `report_field_register` decorator to register a ComputationField subclass and use it by its name in the columns attribute of the ReportView + + + +.. code-block:: python + + from slick_reporting.fields import ComputationField + from slick_reporting.decorators import report_field_register + + + @report_field_register + class TotalQTYReportField(ComputationField): + name = "__total_quantity__" + calculation_field = "quantity" # the field we want to compute on + calculation_method = Sum # What method we want, default to Sum + verbose_name = _("Total quantity") + + + class ProductSales(ReportView): + report_model = SalesTransaction + # .. + columns = [ + # ... + "__total_quantity__", # Use the ComputationField by its registered name + TotalQTYReportField, # Use Computation Field as a class + ComputationField.create( + Sum, "quantity", name="__total_quantity__", verbose_name=_("Total quantity") + ) + # Using the ComputationField.create() method + ] + +What happened here is that we: + +1. Created a ComputationField subclass and gave it the needed attributes +2. Register it via ``report_field_register`` so it can be picked up by the framework. +3. Used it by name inside the columns attribute (or in time_series_columns, or in crosstab_columns) +4. Note that this is same as using the class directly in the columns , also the same as using `ComputationField.create()` + +Another example, adding and AVG to the field `price`: + +.. code-block:: python + + from django.db.models import Avg + from slick_reporting.decorators import report_field_register + + + @report_field_register + class TotalQTYReportField(ComputationField): + name = "__avg_price__" + calculation_field = "price" + calculation_method = Avg + verbose_name = _("Avg. Price") + + + class ProductSales(ReportView): + # .. + columns = [ + "name", + "__avg_price__", + ] + +Using Value of a Computation Field within a another +--------------------------------------------------- + +Sometime you want to stack values on top of each other. For example: Net revenue = Gross revenue - Discounts. + +.. code-block:: python + + class PercentageToTotalBalance(ComputationField): + requires = [BalanceReportField] + name = "__percent_to_total_balance__" + verbose_name = _("%") + calculation_method = Sum + calculation_field = "value" + + prevent_group_by = True + + def resolve( + self, + prepared_results, + required_computation_results: dict, + current_pk, + current_row=None, + ) -> float: + result = super().resolve( + prepared_results, required_computation_results, current_pk, current_row + ) + return required_computation_results.get("__balance__") / result * 100 + + +We need to override ``resolve`` to do the needed calculation. The ``required_computation_results`` is a dictionary of the results of the required fields, where the keys are the names. + +Note: + +1. The ``requires`` attribute is a list of the required fields to be computed before this field. +2. The values of the ``requires`` fields are available in the ``required_computation_results`` dictionary. +3. In the example we used the ``prevent_group_by`` attribute. It's as the name sounds, it prevents the rows from being grouped for teh ComputationField giving us the result over the whole set. + + +How it works ? +-------------- +When the `ReportGenerator` is initialized, it generates a list of the needed fields to be displayed and computed. +Each computation field in the report is given the filters needed and asked to get all the results prepared. +Then for each record, the ReportGenerator again asks each ComputationField to get the data it has for each record and map it back. + + +Customizing the Calculation Flow +-------------------------------- + +The results are prepared in 2 main stages + +1. Preparation: Where you can get the whole result set for the report. Example: Sum of all the values in a model group by the products. +2. resolve: Where you get the value for each record. + + + + +.. code-block:: python + + class MyCustomComputationField(ComputationField): + name = "__custom_field__" + + def prepare( + self, + q_filters: list | object = None, + kwargs_filters: dict = None, + queryset=None, + **kwargs + ): + # do all you calculation here for the whole set if any and return the prepared results + pass + + def resolve( + self, + prepared_results, + required_computation_results: dict, + current_pk, + current_row=None, + ) -> float: + # does the calculation for each record, return a value + pass + +Bundled Report Fields +--------------------- +As this project came form an ERP background, there are some bundled report fields that you can use out of the box. + +* __total__ : `Sum` of the field named `value` +* __total_quantity__ : `Sum` of the field named `quantity` +* __fb__ : First Balance, Sum of the field `value` on the start date (or period in case of time series) +* __balance__: Compound Sum of the field `value`. IE: the sum of the field `value` on end date. +* __credit__: Sum of field Value for the minus_list +* __debit__: Sum of the field value for the plus list +* __percent_to_total_balance__: Percent of the field value to the balance + +What is the difference between total and balance fields ? + +Total: Sum of the value for the period +Balance: Sum of the value for the period + all the previous periods. + +Example: You have a client who buys 10 in Jan., 12 in Feb. and 13 in March: + +* `__total__` will return 10 in Jan, 12 in Feb and 13 in March. +* `__balance__` will return 10 in Jan, 22 in Feb and 35 in March + + + diff --git a/docs/source/topics/crosstab_options.rst b/docs/source/topics/crosstab_options.rst index 14558dc..56ac427 100644 --- a/docs/source/topics/crosstab_options.rst +++ b/docs/source/topics/crosstab_options.rst @@ -27,7 +27,6 @@ Here is a general use case: "name", "__crosstab__", # You can customize where the crosstab columns are displayed in relation to the other columns - ComputationField.create(Sum, "value", verbose_name=_("Total Value")), # This is the same as the calculation in the crosstab, # but this one will be on the whole set. IE total value. @@ -72,7 +71,6 @@ Example: class CrosstabWithIdsCustomFilter(CrosstabReport): crosstab_ids_custom_filters = [ (~Q(product__size__in=["extra_big", "big"]), dict()), - (None, dict(product__size__in=["extra_big", "big"])), ] # Note: @@ -111,16 +109,13 @@ Example 1: On a "regular" crosstab report class CrossTabReportWithCustomVerboseName(CrosstabReport): - crosstab_columns = [ - CustomCrossTabTotalField - ] + crosstab_columns = [CustomCrossTabTotalField] Example 2: On the ``crosstab_ids_custom_filters`` one .. code-block:: python class CustomCrossTabTotalField2(CustomCrossTabTotalField): - @classmethod def get_crosstab_field_verbose_name(cls, model, id): if id == 0: @@ -129,9 +124,7 @@ Example 2: On the ``crosstab_ids_custom_filters`` one class CrossTabReportWithCustomVerboseNameCustomFilter(CrosstabWithIdsCustomFilter): - crosstab_columns = [ - CustomCrossTabTotalField2 - ] + crosstab_columns = [CustomCrossTabTotalField2] diff --git a/docs/source/topics/exporting.rst b/docs/source/topics/exporting.rst index 317b491..3246892 100644 --- a/docs/source/topics/exporting.rst +++ b/docs/source/topics/exporting.rst @@ -7,10 +7,49 @@ To trigger an export to CSV, just add ``?_export=csv`` to the url. This is perfo This will call the export_csv on the view class, engaging a `ExportToStreamingCSV` +Having an `_export` parameter not implemented, ie the view class do not implement ``export_{parameter_name}``, will be ignored. + + +Configuring the CSV export option +--------------------------------- + +You can disable the CSV export option by setting the ``csv_export_class`` attribute to ``False`` on the view class. +and you can override the function and its attributes to customize the button text + +.. code-block:: python + + class CustomExportReport(GroupByReport): + report_title = _("Custom Export Report") + + def export_csv(self, report_data): + return super().export_csv(report_data) + + export_csv.title = _("My Custom CSV export Title") + export_csv.css_class = "btn btn-success" + + +Adding an export option +----------------------- + 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. +Example to add a pdf export option: + +.. code-block:: python + + class CustomExportReport(GroupByReport): + report_title = _("Custom Export Report") + export_actions = ["export_pdf"] + + def export_pdf(self, report_data): + return HttpResponse(f"Dummy PDF Exported {report_data}") + + export_pdf.title = _("Export PDF") + export_pdf.icon = "fa fa-file-pdf-o" + export_pdf.css_class = "btn btn-primary" + +The export function should accept the report_data json response and return the response you want. diff --git a/docs/source/topics/filter_form.rst b/docs/source/topics/filter_form.rst index a68e737..6d4b560 100644 --- a/docs/source/topics/filter_form.rst +++ b/docs/source/topics/filter_form.rst @@ -22,7 +22,7 @@ The system expect that the form used with the ``ReportView`` to implement the `` 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. +* ``get_filters``: Mandatory, return a tuple (Q_filters , 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 @@ -30,7 +30,7 @@ The interface is simple, only 3 mandatory methods to implement, The rest are man * ``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) +* ``get_crispy_helper`` : Optional, return a crispy form helper to be used in rendering the form. In case you are working with a crosstab report, you need to implement the following methods: @@ -81,8 +81,7 @@ Example a full example of a custom form: required=False, label="Show requests from other People Only" ) - def __init__(self, request=None, *args, **kwargs): - self.request = request + def __init__(self, *args, **kwargs): super(RequestLogForm, self).__init__(*args, **kwargs) # provide initial values and ay needed customization self.fields["start_date"].initial = datetime.date.today() diff --git a/docs/source/topics/group_by_report.rst b/docs/source/topics/group_by_report.rst index 81e7c63..82735ed 100644 --- a/docs/source/topics/group_by_report.rst +++ b/docs/source/topics/group_by_report.rst @@ -15,27 +15,31 @@ Example: .. code-block:: python class GroupByReport(ReportView): - report_model = SalesTransaction - report_title = _("Group By Report") - date_field = "date" - group_by = "product" - - columns = [ - "name", - ComputationField.create( - method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, - ), - ] - - # Charts - chart_settings = [ - Chart( - "Total sold $", - Chart.BAR, - data_source=["value__sum"], - title_source=["name"], - ), - ] + report_model = SalesTransaction + report_title = _("Group By Report") + date_field = "date" + group_by = "product" + + columns = [ + "name", + ComputationField.create( + method=Sum, + field="value", + name="value__sum", + verbose_name="Total sold $", + is_summable=True, + ), + ] + + # Charts + chart_settings = [ + Chart( + "Total sold $", + Chart.BAR, + data_source=["value__sum"], + title_source=["name"], + ), + ] A Sample group by report would look like this: @@ -62,7 +66,7 @@ Example: class GroupByTraversingFieldReport(GroupByReport): report_title = _("Group By Traversing Field") - group_by = "product__product_category" # Note the traversing + group_by = "product__product_category" # Note the traversing @@ -89,7 +93,9 @@ Example: columns = [ "__index__", - ComputationField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), + ComputationField.create( + Sum, "value", verbose_name=_("Total Sold $"), name="value" + ), ] chart_settings = [ @@ -109,13 +115,13 @@ Example: def format_row(self, row_obj): # Put the verbose names we need instead of the integer index - index = row_obj['__index__'] + index = row_obj["__index__"] if index == 0: row_obj["__index__"] = "Big" elif index == 1: - row_obj['__index__'] = "Small" + row_obj["__index__"] = "Small" elif index == 2: - row_obj['__index__'] = "Medium" + row_obj["__index__"] = "Medium" return row_obj @@ -144,7 +150,11 @@ Example: columns = [ ComputationField.create( - method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + method=Sum, + field="value", + name="value__sum", + verbose_name="Total sold $", + is_summable=True, ), ] diff --git a/docs/source/topics/index.rst b/docs/source/topics/index.rst index 3cfc7e2..23cd7ec 100644 --- a/docs/source/topics/index.rst +++ b/docs/source/topics/index.rst @@ -33,4 +33,6 @@ You saw how to use the ReportView class in the tutorial and you identified the t filter_form widgets integrating_slick_reporting + charts exporting + computation_field diff --git a/docs/source/topics/time_series_options.rst b/docs/source/topics/time_series_options.rst index 734e3ec..23eaca8 100644 --- a/docs/source/topics/time_series_options.rst +++ b/docs/source/topics/time_series_options.rst @@ -18,6 +18,7 @@ Here is a quick look at the general use case from django.db.models import Sum from slick_reporting.views import ReportView + class TimeSeriesReport(ReportView): report_model = SalesTransaction group_by = "client" @@ -35,29 +36,30 @@ Here is a quick look at the general use case columns = [ "name", "__time_series__", - # This is the same as the time_series_columns, but this one will be on the whole set ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), - ] chart_settings = [ - Chart("Client Sales", - Chart.BAR, - data_source=["sum__value"], - title_source=["name"], - ), - Chart("Total Sales Monthly", - Chart.PIE, - data_source=["sum__value"], - title_source=["name"], - plot_total=True, - ), - Chart("Total Sales [Area chart]", - Chart.AREA, - data_source=["sum__value"], - title_source=["name"], - ) + Chart( + "Client Sales", + Chart.BAR, + data_source=["sum__value"], + title_source=["name"], + ), + Chart( + "Total Sales Monthly", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], + plot_total=True, + ), + Chart( + "Total Sales [Area chart]", + Chart.AREA, + data_source=["sum__value"], + title_source=["name"], + ), ] @@ -106,9 +108,18 @@ Let's see how you can do that, inheriting from teh same Time series we did first report_title = _("Time Series Report With Custom Dates") time_series_pattern = "custom" time_series_custom_dates = ( - (datetime.datetime(get_current_year(), 1, 1), datetime.datetime(get_current_year(), 1, 10)), - (datetime.datetime(get_current_year(), 2, 1), datetime.datetime(get_current_year(), 2, 10)), - (datetime.datetime(get_current_year(), 3, 1), datetime.datetime(get_current_year(), 3, 10)), + ( + datetime.datetime(get_current_year(), 1, 1), + datetime.datetime(get_current_year(), 1, 10), + ), + ( + datetime.datetime(get_current_year(), 2, 1), + datetime.datetime(get_current_year(), 2, 10), + ), + ( + datetime.datetime(get_current_year(), 3, 1), + datetime.datetime(get_current_year(), 3, 10), + ), ) @@ -149,17 +160,21 @@ Example: ] chart_settings = [ - Chart("Client Sales", - Chart.BAR, - data_source=["sum_of_value"], # Note: This is the name of our `TotalSalesField` `field - title_source=["name"], - ), - Chart("Total Sales [Pie]", - Chart.PIE, - data_source=["sum_of_value"], - title_source=["name"], - plot_total=True, - ), + Chart( + "Client Sales", + Chart.BAR, + data_source=[ + "sum_of_value" + ], # Note: This is the name of our `TotalSalesField` `field + title_source=["name"], + ), + Chart( + "Total Sales [Pie]", + Chart.PIE, + data_source=["sum_of_value"], + title_source=["name"], + plot_total=True, + ), ] @@ -187,16 +202,18 @@ Example: ] chart_settings = [ - Chart("Total Sales [Bar]", - Chart.BAR, - data_source=["sum__value"], - title_source=["name"], - ), - Chart("Total Sales [Pie]", - Chart.PIE, - data_source=["sum__value"], - title_source=["name"], - ), + Chart( + "Total Sales [Bar]", + Chart.BAR, + data_source=["sum__value"], + title_source=["name"], + ), + Chart( + "Total Sales [Pie]", + Chart.PIE, + data_source=["sum__value"], + title_source=["name"], + ), ] diff --git a/docs/source/topics/widgets.rst b/docs/source/topics/widgets.rst index fac5f80..142ef45 100644 --- a/docs/source/topics/widgets.rst +++ b/docs/source/topics/widgets.rst @@ -1,8 +1,9 @@ .. _widgets: +.. _dashboard: -Widgets -======= -You can use the report data on any other page, for example to create a dashboard. +Dashboards +========== +You can use the report data and charts on any other page, for example to create a dashboard. A dashboard page is a collection of report results / charts / tables. Adding a widget to a page is as easy as this code @@ -11,19 +12,21 @@ Adding a widget to a page is as easy as this code {% load static slick_reporting_tags %} - {# make sure to have the js_resources added to your page #} + {% block content %} +
+ {% get_widget_from_url url_name="product-sales" %} +
+ {% endblock %} + {% block extrajs %} {% include "slick_reporting/js_resources.html" %} + {% get_charts_media "all" %} {% endblock %} - {% block content %} - {% get_widget_from_url url_name="product-sales" %} - {% endblock %} +The `get_widget_from_url` with create a card block, which will contain the report results and charts. You can customize the widget by passing arguments to the template tag. Arguments --------- -You can pass arguments to the ``get_widget`` function to control aspects of its behavior - * title: string, a title for the widget, default to the report title. * chart_id: the id of the chart that will be rendered as default. @@ -42,20 +45,12 @@ This code above will be actually rendered as this in the html page: .. code-block:: html+django -
+
-
+
> -
+
@@ -65,9 +60,10 @@ This code above will be actually rendered as this in the html page:
-The ``data-report-widget`` attribute is used by the javascript to find the -widget and render the report. -you can add [data-no-auto-load] to the widget to prevent report loader to get the widget data automatically. +The ``data-report-widget`` attribute is used by the javascript to find the widget and render the report. +The ``data-report-chart`` attribute is used by the javascript to find the chart container and render the chart and the chart selector. +The ``data-report-table`` attribute is used by the javascript to find the table container and render the table. + Customization Example --------------------- @@ -90,3 +86,9 @@ The success call-back function will receive the report data as a parameter console.log(data); } + + +Live example: +------------- + +You can see a live example of the widgets in the `Demo project- Dashboard Page `_. diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1c69103..112c612 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -75,8 +75,12 @@ In Slick Reporting, you can do the same thing by creating a report view looking group_by = "product" columns = [ "name", - ComputationField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), - ComputationField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold $"), + ComputationField.create( + Sum, "quantity", verbose_name="Total quantity sold", is_summable=False + ), + ComputationField.create( + Sum, "value", name="sum__value", verbose_name="Total Value sold $" + ), ] chart_settings = [ @@ -132,7 +136,12 @@ You can also export the report to CSV. group_by = "client__country" # notice the double underscore columns = [ "client__country", - ComputationField.create(Sum, "value", name="sum__value", verbose_name="Total Value sold by country $"), + ComputationField.create( + Sum, + "value", + name="sum__value", + verbose_name="Total Value sold by country $", + ), ] chart_settings = [ @@ -164,7 +173,6 @@ A time series report is a report that computes the data for each period of time. name = "my_value_sum" - class MonthlyProductSales(ReportView): report_model = SalesTransaction date_field = "date" @@ -266,9 +274,10 @@ A list report is a report that shows a list of records. For example, if you want report_model = SalesTransaction report_title = "Last 10 sales" date_field = "date" - filters = ["client"] + filters = ["product", "client", "date"] columns = [ - "product", + "product__name", + "client__name", "date", "quantity", "price", @@ -279,6 +288,7 @@ A list report is a report that shows a list of records. For example, if you want + Then again in your urls.py add the following: .. code-block:: python @@ -312,7 +322,7 @@ The system expect that the form used with the ``ReportView`` to implement the `` 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. +* ``get_filters``: Mandatory, return a tuple (Q_filters , 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 @@ -351,7 +361,10 @@ Example required=False, label="End Date", widget=forms.DateInput(attrs={"type": "date"}) ) product_size = forms.ChoiceField( - choices=PRODUCT_SIZE_CHOICES, required=False, label="Product Size", initial="all" + choices=PRODUCT_SIZE_CHOICES, + required=False, + label="Product Size", + initial="all", ) def get_filters(self): @@ -369,6 +382,13 @@ Example q_filters.append(~Q(product__size__in=["extra_big", "big"])) 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"] + + Recap ===== diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py index 1c16be3..343aaab 100644 --- a/slick_reporting/__init__.py +++ b/slick_reporting/__init__.py @@ -1,5 +1,5 @@ default_app_config = "slick_reporting.apps.ReportAppConfig" -VERSION = (1, 1, 1) +VERSION = (1, 2, 0) -__version__ = "1.1.1" +__version__ = "1.2.0" diff --git a/slick_reporting/app_settings.py b/slick_reporting/app_settings.py index 30c1876..1a5a656 100644 --- a/slick_reporting/app_settings.py +++ b/slick_reporting/app_settings.py @@ -41,6 +41,25 @@ def get_end_date(): "JQUERY_URL": SLICK_REPORTING_JQUERY_URL, "DEFAULT_START_DATE_TIME": get_start_date(), "DEFAULT_END_DATE_TIME": get_end_date(), + "DEFAULT_CHARTS_ENGINE": SLICK_REPORTING_DEFAULT_CHARTS_ENGINE, + "MEDIA": { + "override": False, + "js": ( + "https://cdn.jsdelivr.net/momentjs/latest/moment.min.js", + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js", + "https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js", + "https://cdn.datatables.net/1.13.4/js/dataTables.bootstrap5.min.js", + "slick_reporting/slick_reporting.js", + "slick_reporting/slick_reporting.report_loader.js", + "slick_reporting/slick_reporting.datatable.js", + ), + "css": { + "all": ( + "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css", + "https://cdn.datatables.net/1.13.4/css/dataTables.bootstrap5.min.css", + ) + }, + }, "FONT_AWESOME": { "CSS_URL": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css", "ICONS": { @@ -52,17 +71,34 @@ def get_end_date(): }, }, "CHARTS": { - "highcharts": "$.slick_reporting.highcharts.displayChart", - "chartjs": "$.slick_reporting.chartjs.displayChart", + "highcharts": { + "entryPoint": "$.slick_reporting.highcharts.displayChart", + "js": ("https://code.highcharts.com/highcharts.js", "slick_reporting/slick_reporting.highchart.js"), + }, + "chartsjs": { + "entryPoint": "$.slick_reporting.chartsjs.displayChart", + "js": ("https://cdn.jsdelivr.net/npm/chart.js", "slick_reporting/slick_reporting.chartsjs.js"), + }, }, "MESSAGES": { "total": _("Total"), + "export_to_csv": _("Export to CSV"), }, } def get_slick_reporting_settings(): - slick_settings = {**SLICK_REPORTING_SETTINGS_DEFAULT, **getattr(settings, "SLICK_REPORTING_SETTINGS", {})} + slick_settings = SLICK_REPORTING_SETTINGS_DEFAULT.copy() + slick_chart_settings = slick_settings["CHARTS"].copy() + + user_settings = getattr(settings, "SLICK_REPORTING_SETTINGS", {}) + user_chart_settings = user_settings.get("CHARTS", {}) + + slick_chart_settings.update(user_chart_settings) + slick_settings.update(user_settings) + slick_settings["CHARTS"] = slick_chart_settings + + # slick_settings = {**SLICK_REPORTING_SETTINGS_DEFAULT, **getattr(settings, "SLICK_REPORTING_SETTINGS", {})} start_date = getattr(settings, "SLICK_REPORTING_DEFAULT_START_DATE", False) end_date = getattr(settings, "SLICK_REPORTING_DEFAULT_END_DATE", False) # backward compatibility, todo remove in next major release @@ -75,3 +111,7 @@ def get_slick_reporting_settings(): SLICK_REPORTING_SETTINGS = lazy(get_slick_reporting_settings, dict)() + + +def get_media(): + return SLICK_REPORTING_SETTINGS["MEDIA"] diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py index f0f461c..269136d 100644 --- a/slick_reporting/fields.py +++ b/slick_reporting/fields.py @@ -1,10 +1,11 @@ +from __future__ import annotations + from warnings import warn from django.db.models import Sum, Q from django.template.defaultfilters import date as date_filter from django.utils.translation import gettext_lazy as _ -from .helpers import get_calculation_annotation from .registry import field_registry @@ -126,27 +127,22 @@ def __init__( self.group_by = self.group_by or group_by self._cache = None, None, None self._require_classes = self._get_required_classes() + self._required_prepared_results = None - if not self.plus_side_q and not self.minus_side_q: - self._debit_and_credit = False + self._debit_and_credit = self.plus_side_q or self.minus_side_q @classmethod def _get_required_classes(cls): requires = cls.requires or [] return [field_registry.get_field_by_name(x) if isinstance(x, str) else x for x in requires] - def apply_q_plus_filter(self, qs): - return qs.filter(*self.plus_side_q) - - def apply_q_minus_filter(self, qs): - return qs.filter(*self.minus_side_q) - def apply_aggregation(self, queryset, group_by=""): annotation = self.calculation_method(self.calculation_field) if self.group_by_custom_querysets: return queryset.aggregate(annotation) elif group_by: queryset = queryset.values(group_by).annotate(annotation) + queryset = {str(x[self.group_by]): x for x in queryset} else: queryset = queryset.aggregate(annotation) return queryset @@ -161,12 +157,21 @@ def init_preparation(self, q_filters=None, kwargs_filters=None, **kwargs): """ kwargs_filters = kwargs_filters or {} - dep_values = self._prepare_dependencies(q_filters, kwargs_filters.copy()) + required_prepared_results = self._prepare_required_computations(q_filters, kwargs_filters.copy()) + queryset = self.get_queryset() if self.group_by_custom_querysets: debit_results, credit_results = self.prepare_custom_group_by_queryset(q_filters, kwargs_filters, **kwargs) else: - debit_results, credit_results = self.prepare(q_filters, kwargs_filters, **kwargs) - self._cache = debit_results, credit_results, dep_values + debit_results, credit_results = self.prepare( + q_filters, + kwargs_filters, + queryset, + self.group_by, + self.prevent_group_by, + **kwargs, + ) + self._cache = debit_results, credit_results + self._required_prepared_results = required_prepared_results def prepare_custom_group_by_queryset(self, q_filters=None, kwargs_filters=None, **kwargs): debit_output, credit_output = [], [] @@ -178,20 +183,34 @@ def prepare_custom_group_by_queryset(self, q_filters=None, kwargs_filters=None, credit_output.append(credit) return debit_output, credit_output - def prepare(self, q_filters=None, kwargs_filters=None, queryset=None, **kwargs): + def prepare( + self, + q_filters: list | object = None, + kwargs_filters: dict = None, + main_queryset=None, + group_by: str = None, + prevent_group_by=None, + **kwargs, + ): """ This is the first hook where you can customize the calculation away from the Django Query aggregation method - This method et called with all available parameters , so you can prepare the results for the whole set and save + This method is called with all available arguments, so you can prepare the results for the whole set and save it in a local cache (like self._cache) . The flow will later call the method `resolve`, giving you the id, for you to return it respective calculation :param q_filters: :param kwargs_filters: + :param main_queryset: + :param group_by: + :param prevent_group_by: :param kwargs: :return: """ - queryset = queryset or self.get_queryset() - group_by = "" if self.prevent_group_by else self.group_by + + queryset = main_queryset.all() + group_by = "" if prevent_group_by else group_by + credit_results = None + if q_filters: if type(q_filters) is Q: q_filters = [q_filters] @@ -200,19 +219,17 @@ def prepare(self, q_filters=None, kwargs_filters=None, queryset=None, **kwargs): queryset = queryset.filter(**kwargs_filters) if self.plus_side_q: - queryset = self.apply_q_plus_filter(queryset) + queryset = queryset.filter(*self.plus_side_q) debit_results = self.apply_aggregation(queryset, group_by) - credit_results = None if self._debit_and_credit: - queryset = self.get_queryset() + queryset = main_queryset.all() if kwargs_filters: queryset = queryset.filter(**kwargs_filters) if q_filters: queryset = queryset.filter(*q_filters) if self.minus_side_q: - queryset = self.apply_q_minus_filter(queryset) - + queryset = queryset.filter(*self.minus_side_q) credit_results = self.apply_aggregation(queryset, group_by) return debit_results, credit_results @@ -225,21 +242,14 @@ def get_queryset(self): queryset = queryset.filter(**self.base_kwargs_filters) return queryset.order_by() - def get_annotation_name(self): - """ - Get the annotation per the database - :return: string used ex: - """ - return get_calculation_annotation(self.calculation_field, self.calculation_method) - - def _prepare_dependencies( + def _prepare_required_computations( self, q_filters=None, extra_filters=None, ): values = {} - for dep_class in self._require_classes: - dep = dep_class( + for required_klass in self._require_classes: + dep = required_klass( self.plus_side_q, self.minus_side_q, self.report_model, @@ -252,90 +262,67 @@ def _prepare_dependencies( values[dep.name] = {"results": results, "instance": dep} return values - def resolve(self, current_obj, current_row=None): + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: """ Reponsible for getting the exact data from the prepared value - :param cached: the returned data from prepare - :param current_obj: he value of group by id + :param prepared_results: the returned data from prepare + :param required_computation_results: the returned data from prepare + :param current_pk: he value of group by id :param current_row: the row in iteration :return: a solid number or value """ - cached = self._cache - debit_value, credit_value = self.extract_data(cached, current_obj) - dependencies_value = self._resolve_dependencies(current_obj) + debit_value, credit_value = self.extract_data(prepared_results, current_pk) + value = debit_value or 0 - credit_value or 0 + return value - return self.final_calculation(debit_value, credit_value, dependencies_value) + def do_resolve(self, current_obj, current_row=None): + prepared_result = self._cache + dependencies_value = self._resolve_dependencies(current_obj) + return self.resolve(prepared_result, dependencies_value, current_obj, current_row) - def get_dependency_value(self, current_obj, name=None): + def get_dependency_value(self, current_obj, name): """ Get the values of the ReportFields specified in `requires` :param current_obj: the current object which we want the calculation for - :param name: Optional, the name of the specific dependency you want. + :param name: the name of the specific dependency you want. :return: a dict containing dependencies names as keys and their calculation as values or a specific value if name is specified. """ - values = self._resolve_dependencies(current_obj, name=None) - if name: - return values.get(name) - return values + values = self._resolve_dependencies(current_obj, name=name) + return values.get(name) def _resolve_dependencies(self, current_obj, name=None): dep_results = {} - cached_debit, cached_credit, dependencies_value = self._cache + dependencies_value = self._required_prepared_results dependencies_value = dependencies_value or {} - for d in dependencies_value.keys(): - if name and d != name: - continue + needed_values = [name] if name else dependencies_value.keys() + for d in needed_values: d_instance = dependencies_value[d]["instance"] - dep_results[d] = d_instance.resolve(current_obj) + dep_results[d] = d_instance.do_resolve(current_obj) return dep_results - def extract_data(self, cached, current_obj): + def extract_data(self, prepared_results, current_obj): 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() + annotation = "__".join([self.calculation_field.lower(), self.calculation_method.name.lower()]) - cached_debit, cached_credit, dependencies_value = cached + cached_debit, cached_credit = prepared_results - if cached_debit or cached_credit: - debit = None - if cached_debit is not None: + cached = [cached_debit, cached_credit] + output = [] + for results in cached: + value = 0 + if results: if not group_by: - x = list(cached_debit.keys())[0] - debit_value = cached_debit[x] + x = list(results.keys())[0] + value = results[x] elif self.group_by_custom_querysets: - debit = cached_debit[int(current_obj)] - debit_value = debit[annotation] + value = results[int(current_obj)][annotation] else: - for i, x in enumerate(cached_debit): - if str(x[group_by]) == current_obj: - debit = cached_debit[i] - break - if debit: - debit_value = debit[annotation] - - if cached_credit is not None: - credit = None - if cached_credit is not None: - if not group_by: - x = list(cached_credit.keys())[0] - credit_value = cached_credit[x] - else: - for i, x in enumerate(cached_credit): - if str(x[group_by]) == current_obj: - credit = cached_credit[i] - break - if credit: - credit_value = credit[annotation] - return debit_value, credit_value - - def final_calculation(self, debit, credit, dep_dict): - debit = debit or 0 - credit = credit or 0 - return debit - credit + value = results.get(str(current_obj), {}).get(annotation, 0) + output.append(value) + return output @classmethod def get_full_dependency_list(cls): @@ -399,13 +386,23 @@ class FirstBalanceField(ComputationField): name = "__fb__" verbose_name = _("opening balance") - def prepare(self, q_filters=None, extra_filters=None, **kwargs): - extra_filters = extra_filters or {} + def prepare( + self, + q_filters: list | object = None, + kwargs_filters: dict = None, + main_queryset=None, + group_by: str = None, + prevent_group_by=None, + **kwargs, + ): + extra_filters = kwargs_filters or {} from_date_value = extra_filters.get(f"{self.date_field}__gte") extra_filters.pop(f"{self.date_field}__gte", None) extra_filters[f"{self.date_field}__lt"] = from_date_value - return super(FirstBalanceField, self).prepare(q_filters, extra_filters) + return super(FirstBalanceField, self).prepare( + q_filters, kwargs_filters, main_queryset, group_by, prevent_group_by, **kwargs + ) field_registry.register(FirstBalanceField) @@ -425,36 +422,35 @@ class BalanceReportField(ComputationField): verbose_name = _("Closing Total") requires = ["__fb__"] - def final_calculation(self, debit, credit, dep_dict): - fb = dep_dict.get("__fb__") - debit = debit or 0 - credit = credit or 0 - fb = fb or 0 - return fb + debit - credit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) + fb = required_computation_results.get("__fb__") or 0 + + return result + fb field_registry.register(BalanceReportField) -class PercentageToBalance(ComputationField): +class PercentageToTotalBalance(ComputationField): requires = [BalanceReportField] - name = "PercentageToBalance" + name = "__percent_to_total_balance__" verbose_name = _("%") prevent_group_by = True - def final_calculation(self, debit, credit, dep_dict): - obj_balance = dep_dict.get("__balance__") - total = debit - credit - return (obj_balance / total) * 100 + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) + return required_computation_results.get("__balance__") / result * 100 class CreditReportField(ComputationField): name = "__credit__" verbose_name = _("Credit") - def final_calculation(self, debit, credit, dep_dict): - return credit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + debit_value, credit_value = self.extract_data(prepared_results, current_pk) + return credit_value field_registry.register(CreditReportField) @@ -465,8 +461,9 @@ class DebitReportField(ComputationField): name = "__debit__" verbose_name = _("Debit") - def final_calculation(self, debit, credit, dep_dict): - return debit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + debit_value, credit_value = self.extract_data(prepared_results, current_pk) + return debit_value @field_registry.register @@ -476,8 +473,9 @@ class CreditQuantityReportField(ComputationField): calculation_field = "quantity" is_summable = False - def final_calculation(self, debit, credit, dep_dict): - return credit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + debit_value, credit_value = self.extract_data(prepared_results, current_pk) + return credit_value @field_registry.register @@ -487,8 +485,9 @@ class DebitQuantityReportField(ComputationField): verbose_name = _("Debit QTY") is_summable = False - def final_calculation(self, debit, credit, dep_dict): - return debit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + debit_value, credit_value = self.extract_data(prepared_results, current_pk) + return debit_value class TotalQTYReportField(ComputationField): @@ -518,11 +517,10 @@ class BalanceQTYReportField(ComputationField): requires = ["__fb_quantity__"] is_summable = False - def final_calculation(self, debit, credit, dep_dict): - # Use `get` so it fails loud if its not there - fb = dep_dict.get("__fb_quantity__") - fb = fb or 0 - return fb + debit - credit + def resolve(self, prepared_results, required_computation_results: dict, current_pk, current_row=None) -> float: + result = super().resolve(prepared_results, required_computation_results, current_pk, current_row) + fb = required_computation_results.get("__fb_quantity__") or 0 + return result + fb field_registry.register(BalanceQTYReportField) diff --git a/slick_reporting/forms.py b/slick_reporting/forms.py index 07e0544..b81e6f8 100644 --- a/slick_reporting/forms.py +++ b/slick_reporting/forms.py @@ -275,14 +275,14 @@ def report_form_factory( fields["start_date"] = forms.DateTimeField( required=False, label=_("From date"), - initial=initial.get("start_date", app_settings.SLICK_REPORTING_DEFAULT_START_DATE), + initial=initial.get("start_date", "") or app_settings.SLICK_REPORTING_SETTINGS["DEFAULT_START_DATE_TIME"], widget=forms.DateTimeInput(attrs={"autocomplete": "off"}), ) fields["end_date"] = forms.DateTimeField( required=False, label=_("To date"), - initial=initial.get("end_date", app_settings.SLICK_REPORTING_DEFAULT_END_DATE), + initial=initial.get("end_date", "") or app_settings.SLICK_REPORTING_SETTINGS["DEFAULT_END_DATE_TIME"], widget=forms.DateTimeInput(attrs={"autocomplete": "off"}), ) @@ -303,6 +303,7 @@ def report_form_factory( field_attrs = foreign_key_widget_func(f_field) if name in required: field_attrs["required"] = True + field_attrs["initial"] = initial.get(name, "") fields[name] = f_field.formfield(**field_attrs) if crosstab_model: diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index c8bab46..8c9aafb 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -10,6 +10,7 @@ from .fields import ComputationField from .helpers import get_field_from_query_text from .registry import field_registry +from . import app_settings logger = logging.getLogger(__name__) @@ -22,6 +23,7 @@ class Chart: title_source: list plot_total: bool = False engine: str = "" + entryPoint: str = "" COLUMN = "column" LINE = "line" PIE = "pie" @@ -36,6 +38,7 @@ def to_dict(self): title_source=self.title_source, plot_total=self.plot_total, engine=self.engine, + entryPoint=self.entryPoint, ) @@ -507,13 +510,14 @@ def _get_record_data(self, obj, columns): if source: computation_class = self.report_fields_classes[source] + # the computation field is being asked from another computation field that requires it. value = computation_class.get_dependency_value(group_by_val, col_data["ref"].name) else: try: computation_class = self.report_fields_classes[name] except KeyError: continue - value = computation_class.resolve(group_by_val, data) + value = computation_class.do_resolve(group_by_val, data) if self.swap_sign: value = -value data[name] = value @@ -928,7 +932,8 @@ def get_full_response( } return data - def get_chart_settings(self, chart_settings=None, default_chart_title=None, chart_engine=None): + @staticmethod + def get_chart_settings(chart_settings=None, default_chart_title=None, chart_engine=None): """ Ensure the sane settings are passed to the front end. """ @@ -939,7 +944,6 @@ def get_chart_settings(self, chart_settings=None, default_chart_title=None, char for i, chart in enumerate(chart_settings): if type(chart) is Chart: chart = chart.to_dict() - chart["id"] = chart.get("id", f"{i}") chart_type = chart.get("type", "line") if chart_type == "column" and SLICK_REPORTING_DEFAULT_CHARTS_ENGINE == "chartsjs": @@ -948,6 +952,11 @@ def get_chart_settings(self, chart_settings=None, default_chart_title=None, char if not chart.get("title", False): chart["title"] = report_title chart["engine_name"] = chart.get("engine_name", chart_engine) + chart["entryPoint"] = ( + chart.get("entryPoint") + or app_settings.SLICK_REPORTING_SETTINGS["CHARTS"][chart["engine_name"]]["entryPoint"] + ) + output.append(chart) return output @@ -1018,7 +1027,7 @@ def _get_record_data(self, obj, columns): computation_class = self.report_fields_classes[name] except KeyError: continue - value = computation_class.resolve(group_by_val, data) + value = computation_class.do_resolve(group_by_val, data) if self.swap_sign: value = -value data[name] = value diff --git a/slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js b/slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js index f8f3447..304de13 100644 --- a/slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js @@ -17,8 +17,8 @@ return response['metadata']['time_series_column_names']; } - function createChartObject(response, chartId, extraOptions) { - let chartOptions = $.slick_reporting.getObjFromArray(response.chart_settings, 'id', chartId, true); + function createChartObject(response, chartOptions, extraOptions) { + // let chartOptions = $.slick_reporting.getObjFromArray(response.chart_settings, 'id', chartId, true); let extractedData = extractDataFromResponse(response, chartOptions); let chartObject = { @@ -69,6 +69,27 @@ return chartObject } + function getGroupByLabelAndSeries(response, chartOptions) { + + let legendResults = []; + let datasetData = []; + let dataFieldName = chartOptions['data_source']; + let titleFieldName = chartOptions['title_source']; + + for (let i = 0; i < response.data.length; i++) { + let row = response.data[i]; + if (titleFieldName !== '') { + let txt = row[titleFieldName]; + txt = $(txt).text() || txt; // the title is an "); } @@ -162,7 +175,7 @@ console.error(e) } - let chartObject = $.slick_reporting.chartsjs.createChartObject(data, chart_id); + let chartObject = $.slick_reporting.chartsjs.createChartObject(data, chartOptions); let $chart = $elem.find('canvas'); try { _chart_cache[cache_key] = new Chart($chart, chartObject); @@ -178,6 +191,7 @@ $.slick_reporting = {} } $.slick_reporting.chartsjs = { + getGroupByLabelAndSeries: getGroupByLabelAndSeries, createChartObject: createChartObject, displayChart: displayChart, defaults: { diff --git a/slick_reporting/static/slick_reporting/slick_reporting.highchart.js b/slick_reporting/static/slick_reporting/slick_reporting.highchart.js index 219d917..c7a3c1e 100644 --- a/slick_reporting/static/slick_reporting/slick_reporting.highchart.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.highchart.js @@ -1,5 +1,5 @@ /** - * Created by ramez on 11/20/14. + * Created by Ramez on 11/20/14. */ (function ($) { @@ -19,39 +19,7 @@ return output } - function getObjFromArray(objList, obj_key, key_value, failToFirst) { - failToFirst = typeof (failToFirst) !== 'undefined'; - if (key_value !== '') { - for (let i = 0; i < objList.length; i++) { - if (objList[i][obj_key] === key_value) { - return objList[i]; - } - } - } - if (failToFirst && objList.length > 0) { - return objList[0] - } - - return false; - } - - var ra_chart_settings = { - - - //exporting: { - // allowHTML:true, - // enabled: faelse, - //}, - - func2: function () { - var tooltip = '' + this.point.key + '' + - '' + - '
' + this.series.name + ': ' + - this.point.y + '
{Percentage}:' + this.point.percentage + ' %
' - } - }; let _chart_cache = {}; function normalStackedTooltipFormatter() { @@ -82,20 +50,14 @@ } } - function createChartObject(response, chart_id, extraOptions) { + function createChartObject(response, chartOptions, extraOptions) { // Create the chart Object - // First specifying the global default - // second, Get the data from the serponse - // Adjust the Chart Object accordingly - let chartOptions = getObjFromArray(response.chart_settings, 'id', chart_id, true) + // First specifying the global defaults then apply teh specification from the response try { - - $.extend(chartOptions, { 'sub_title': '', }); - // chartOptions = getChartOptions(isGroup, response, chartOptions); chartOptions.data = response.data; @@ -103,7 +65,7 @@ let is_crosstab = is_crosstab_support(response, chartOptions); let chart_type = chartOptions.type; - var enable3d = false; + let enable3d = false; let chart_data = {}; let rtl = false; // $.slick_reporting.highcharts.defaults.rtl; @@ -120,25 +82,18 @@ let highchart_object = { chart: { type: '', - //renderTo: 'container', - //printWidth: 600 }, title: { text: chartOptions.title, - // useHTML: Highcharts.hasBidiBug - //useHTML: true }, subtitle: { text: chartOptions.sub_title, useHTML: Highcharts.hasBidiBug - //useHTML: true }, yAxis: { - // title: {text: chartyAxisTitle}, opposite: rtl, }, xAxis: { - // title: {text: chartxAxisTitle}, labels: {enabled: true}, reversed: rtl, }, @@ -150,7 +105,6 @@ allowHTML: true, enabled: true, - //scale:2, } }; @@ -163,9 +117,6 @@ if (chart_type === 'bar' || chart_type === 'column') { highchart_object['xAxis'] = { categories: chart_data['titles'], - // title: { - // text: null - // } }; } highchart_object['yAxis']['labels'] = {overflow: 'justify'}; @@ -188,18 +139,6 @@ } }; - // highchart_object.tooltip = { - // useHTML: true, - // headerFormat: '{point.key}', - // pointFormat: '' + - // '' + - // - // '' + - // '', - // footerFormat: '
{series.name}: {point.y}
' + $.ra.highcharts.defaults.messages.percent + '{point.percentage:.1f} %
', - // valueDecimals: 2 - // }; - highchart_object['legend'] = { layout: 'vertical', align: 'right', @@ -415,10 +354,7 @@ 'name': col_dict[col].verbose_name, 'data': [totalValues[col]] }) - }) - - }) } return { @@ -438,16 +374,13 @@ return response.metadata.crosstab_model || '' } - function displayChart(data, $elem, chart_id) { - chart_id = chart_id || $elem.attr('data-report-default-chart') || ''; + function displayChart(data, $elem, chartOptions) { if ($elem.find("div[data-inner-chart-container]").length === 0) { $elem.append('
') } let chart = $elem.find("div[data-inner-chart-container]") - // chart.append(""); - // let chartObject = getObjFromArray(data.chart_settings, 'id', chart_id, true); - let cache_key = data.report_slug + ':' + chart_id + let cache_key = data.report_slug + ':' + chartOptions.id; try { let existing_chart = _chart_cache[cache_key]; if (typeof (existing_chart) !== 'undefined') { @@ -457,7 +390,7 @@ console.error(e) } - chartObject = $.slick_reporting.highcharts.createChartObject(data, chart_id); + let chartObject = $.slick_reporting.highcharts.createChartObject(data, chartOptions); _chart_cache[cache_key] = chart.highcharts(chartObject); } @@ -473,15 +406,12 @@ percent: 'Percent', }, credits: { - // text: 'RaSystems.io', - // href: 'https://rasystems.io' + // text: '', + // href: '' }, - // notify_error: notify_error, enable3d: false, - } }; - } (jQuery) diff --git a/slick_reporting/static/slick_reporting/slick_reporting.js b/slick_reporting/static/slick_reporting/slick_reporting.js index 5442699..bc575d9 100644 --- a/slick_reporting/static/slick_reporting/slick_reporting.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.js @@ -10,11 +10,11 @@ try { func = context[func]; if (typeof func == 'undefined') { - throw 'Function {0} is not found the context {1}'.format(functionName, context); + throw `Function ${functionName} is not found in the context ${context}` } } catch (err) { - console.error('Function {0} is not found the context {1}'.format(functionName, context), err) + console.error(`Function ${functionName} is not found in the context ${context}`, err) } return func.apply(context, args); } diff --git a/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js b/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js index 105265d..f36f28b 100644 --- a/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js @@ -37,16 +37,9 @@ function displayChart(data, $elem, chart_id) { let engine = "highcharts"; - try { - if (chart_id === '' || typeof (chart_id) === "undefined") { - engine = data.chart_settings[0]['engine_name']; - } else { - engine = data.chart_settings.find(x => x.id === chart_id).engine_name; - } - } catch (e) { - console.error(e); - } - $.slick_reporting.executeFunctionByName($.slick_reporting.report_loader.chart_engines[engine], window, data, $elem, chart_id); + let chartOptions = $.slick_reporting.getObjFromArray(data.chart_settings, 'id', chart_id, true); + let entryPoint = chartOptions.entryPoint || $.slick_reporting.report_loader.chart_engines[engine]; + $.slick_reporting.executeFunctionByName(entryPoint, window, data, $elem, chartOptions); } @@ -86,14 +79,26 @@ function initialize() { settings = JSON.parse(document.getElementById('slick_reporting_settings').textContent); + let chartSettings = {}; $('[data-report-widget]').not('[data-no-auto-load]').each(function (i, elem) { refreshReportWidget($(elem)); }); + + Object.keys(settings["CHARTS"]).forEach(function (key) { + chartSettings[key] = settings.CHARTS[key].entryPoint; + }) + $.slick_reporting.report_loader.chart_engines = chartSettings; + try { + $("select").select2(); + } catch (e) { + console.error(e); + } + $.slick_reporting.defaults.total_label = settings["MESSAGES"]["TOTAL_LABEL"]; } function _get_chart_icon(chart_type) { try { - return ""; + return ""; } catch (e) { console.error(e); } @@ -136,17 +141,33 @@ }); + $('[data-export-btn]').on('click', function (e) { + let $elem = $(this); + e.preventDefault() + let form = $($elem.attr('data-form-selector')); + window.location = '?' + form.serialize() + '&_export=' + $elem.attr('data-export-parameter'); + }); + $('[data-get-results-button]').not(".vanilla-btn-flag").on('click', function (event) { + event.preventDefault(); + let $elem = $('[data-report-widget]') + $.slick_reporting.report_loader.refreshReportWidget($elem) + }); + + jQuery(document).ready(function () { + $.slick_reporting.report_loader.initialize(); + }); + + + $.slick_reporting.report_loader = { cache: $.slick_reporting.cache, + // "extractDataFromResponse": extractDataFromResponse, initialize: initialize, refreshReportWidget: refreshReportWidget, failFunction: failFunction, displayChart: displayChart, createChartsUIfromResponse: createChartsUIfromResponse, successCallback: loadComponents, - "chart_engines": { - 'highcharts': '$.slick_reporting.highcharts.displayChart', - "chartsjs": '$.slick_reporting.chartsjs.displayChart', - } + } })(jQuery); \ No newline at end of file diff --git a/slick_reporting/templates/slick_reporting/base.html b/slick_reporting/templates/slick_reporting/base.html index 6795984..6464070 100644 --- a/slick_reporting/templates/slick_reporting/base.html +++ b/slick_reporting/templates/slick_reporting/base.html @@ -32,9 +32,11 @@

{{ report_title }}

-{% include "slick_reporting/js_resources.html" %} + {% block extrajs %} + {% include "slick_reporting/js_resources.html" %} + {% endblock %} \ No newline at end of file diff --git a/slick_reporting/templates/slick_reporting/js_resources.html b/slick_reporting/templates/slick_reporting/js_resources.html index 67b51f3..26263d2 100644 --- a/slick_reporting/templates/slick_reporting/js_resources.html +++ b/slick_reporting/templates/slick_reporting/js_resources.html @@ -3,51 +3,10 @@ {% get_slick_reporting_settings as slick_reporting_settings %} {% add_jquery %} - - - - - - - +{% get_slick_reporting_media as media %} +{{ media }} - - - - - - - - - - {{ slick_reporting_settings|json_script:"slick_reporting_settings" }} - - diff --git a/slick_reporting/templates/slick_reporting/report.html b/slick_reporting/templates/slick_reporting/report.html index 51d7903..8665422 100644 --- a/slick_reporting/templates/slick_reporting/report.html +++ b/slick_reporting/templates/slick_reporting/report.html @@ -1,5 +1,5 @@ {% extends 'slick_reporting/base.html' %} -{% load crispy_forms_tags i18n %} +{% load crispy_forms_tags i18n slick_reporting_tags %} {% block content %}
@@ -13,13 +13,20 @@

{% trans "Filters" %}

{% endif %} -
+
{% trans "Results" %}
@@ -38,4 +45,10 @@
{% trans "Results" %}
+ {% endblock %} + +{% block extrajs %} + {{ block.super }} + {% get_charts_media report.get_chart_settings %} +{% endblock %} \ No newline at end of file diff --git a/slick_reporting/templatetags/slick_reporting_tags.py b/slick_reporting/templatetags/slick_reporting_tags.py index 543787e..a2ff394 100644 --- a/slick_reporting/templatetags/slick_reporting_tags.py +++ b/slick_reporting/templatetags/slick_reporting_tags.py @@ -1,35 +1,14 @@ from django import template from django.template.loader import get_template +from django.forms import Media from django.urls import reverse, resolve from django.utils.safestring import mark_safe -from slick_reporting.app_settings import SLICK_REPORTING_JQUERY_URL +from ..app_settings import SLICK_REPORTING_JQUERY_URL, SLICK_REPORTING_SETTINGS, get_media register = template.Library() -@register.simple_tag -def get_data(row, column): - return row[column["name"]] - - -# -# def jsonify(object): -# def date_handler(obj): -# if hasattr(obj, "isoformat"): -# return obj.isoformat() -# elif isinstance(obj, Promise): -# return force_str(obj) -# -# if isinstance(object, QuerySet): -# return serialize("json", object) -# -# return mark_safe(json.dumps(object, use_decimal=True, default=date_handler)) -# -# -# register.filter("jsonify", jsonify) - - @register.simple_tag def get_widget_from_url(url_name=None, url=None, **kwargs): _url = "" @@ -79,7 +58,28 @@ def add_jquery(): @register.simple_tag -def get_slick_reporting_settings(): - from slick_reporting.app_settings import SLICK_REPORTING_SETTINGS +def get_charts_media(chart_settings): + charts_dict = SLICK_REPORTING_SETTINGS["CHARTS"] + media = Media() + if chart_settings == "all": + available_types = charts_dict.keys() + else: + available_types = [chart["engine_name"] for chart in chart_settings] + available_types = set(available_types) + + for type in available_types: + media += Media(css=charts_dict.get(type, {}).get("css", {}), js=charts_dict.get(type, {}).get("js", [])) + return media + - return SLICK_REPORTING_SETTINGS +@register.simple_tag +def get_slick_reporting_media(): + from django.forms import Media + + media = get_media() + return Media(css=media["css"], js=media["js"]) + + +@register.simple_tag +def get_slick_reporting_settings(): + return dict(SLICK_REPORTING_SETTINGS) diff --git a/slick_reporting/views.py b/slick_reporting/views.py index d497528..d242003 100644 --- a/slick_reporting/views.py +++ b/slick_reporting/views.py @@ -116,6 +116,8 @@ class ReportViewBase(ReportGeneratorAPI, FormView): template_name = "slick_reporting/report.html" + export_actions = None + @staticmethod def form_filter_func(fkeys_dict): # todo revise @@ -152,6 +154,33 @@ def get_doc_types_q_filters(self): return [], [] + def get_export_actions(self): + """ + Hook to get the export options + :return: list of export options + """ + actions = ["export_csv"] if self.csv_export_class else [] + + if self.export_actions: + actions = actions + self.export_actions + + export_actions = [] + + for action in actions: + func = getattr(self, action, None) + parameter = action.replace("export_", "") + + export_actions.append( + { + "name": action, + "title": getattr(func, "title", action.replace("_", " ").title()), + "icon": getattr(func, "icon", ""), + "css_class": getattr(func, "css_class", ""), + "parameter": parameter, + } + ) + return export_actions + def get(self, request, *args, **kwargs): form_class = self.get_form_class() self.form = self.get_form(form_class) @@ -181,6 +210,10 @@ def get(self, request, *args, **kwargs): def export_csv(self, report_data): return self.csv_export_class(self.request, report_data, self.report_title).get_response() + export_csv.title = SLICK_REPORTING_SETTINGS["MESSAGES"]["export_to_csv"] + export_csv.css_class = "btn btn-primary" + export_csv.icon = "" + @classmethod def get_report_model(cls): if cls.queryset is not None: @@ -242,12 +275,12 @@ def get_form_kwargs(self): } ) elif self.request.method in ("GET", "PUT"): - # elif self.request.GET: - kwargs.update( - { - "data": self.request.GET, - } - ) + if self.request.GET or self.request.headers.get("x-requested-with") == "XMLHttpRequest": + kwargs.update( + { + "data": self.request.GET, + } + ) return kwargs def get_crosstab_ids(self): @@ -257,7 +290,11 @@ def get_crosstab_ids(self): """ return self.form.get_crosstab_ids() - def get_report_generator(self, queryset, for_print): + def get_group_by_custom_querysets(self): + return self.group_by_custom_querysets + + def get_report_generator(self, queryset=None, for_print=False): + queryset = queryset or self.get_queryset() q_filters, kw_filters = self.form.get_filters() crosstab_compute_remainder = False if self.crosstab_field: @@ -293,7 +330,7 @@ def get_report_generator(self, queryset, for_print): swap_sign=self.swap_sign, columns=self.columns, group_by=self.group_by, - group_by_custom_querysets=self.group_by_custom_querysets, + group_by_custom_querysets=self.get_group_by_custom_querysets(), group_by_custom_querysets_column_verbose_name=self.group_by_custom_querysets_column_verbose_name, time_series_pattern=time_series_pattern, time_series_columns=self.time_series_columns, @@ -354,11 +391,15 @@ def get_metadata(cls, generator): """ return generator.get_metadata() - def get_chart_settings(self, generator): + def get_chart_settings(self, generator=None): """ Ensure the sane settings are passed to the front end. """ - return generator.get_chart_settings(self.chart_settings or [], self.report_title, self.chart_engine) + return self.report_generator_class.get_chart_settings( + chart_settings=self.chart_settings or [], + default_chart_title=self.report_title, + chart_engine=self.chart_engine, + ) @classmethod def get_queryset(cls): @@ -381,10 +422,14 @@ def get_report_slug(cls): return cls.report_slug or cls.__name__.lower() def get_initial(self): - return { - "start_date": SLICK_REPORTING_SETTINGS["DEFAULT_START_DATE_TIME"], - "end_date": SLICK_REPORTING_SETTINGS["DEFAULT_END_DATE_TIME"], - } + initial = self.initial.copy() + initial.update( + { + "start_date": SLICK_REPORTING_SETTINGS["DEFAULT_START_DATE_TIME"], + "end_date": SLICK_REPORTING_SETTINGS["DEFAULT_END_DATE_TIME"], + } + ) + return initial def get_form_crispy_helper(self): """ @@ -400,10 +445,10 @@ def get_context_data(self, **kwargs): context[self.report_title_context_key] = self.report_title context["crispy_helper"] = self.get_form_crispy_helper() context["auto_load"] = self.auto_load + context["report"] = self if not (self.request.POST or self.request.GET): - # initialize empty form with initials if the no data is in the get or the post - context["form"] = self.get_form_class()() + context["form"] = self.get_form_class()(**self.get_form_kwargs()) return context def form_invalid(self, form): @@ -433,7 +478,7 @@ def __init_subclass__(cls) -> None: super().__init_subclass__() -class SlickReportingListViewMixin: +class SlickReportingListViewMixin(ReportViewBase): report_generator_class = ListViewReportGenerator filters = None @@ -472,13 +517,13 @@ def get_form_filters(self, form): def get_form_crispy_helper(self): return get_crispy_helper(self.filters) - def get_report_generator(self, queryset, for_print): + def get_report_generator(self, queryset=None, for_print=False): q_filters, kw_filters = self.get_form_filters(self.form) return self.report_generator_class( self.get_report_model(), - start_date=self.form.get_start_date(), - end_date=self.form.get_end_date(), + # start_date=self.form.get_start_date(), + # end_date=self.form.get_end_date(), q_filters=q_filters, kwargs_filters=kw_filters, date_field=self.date_field, @@ -496,7 +541,7 @@ def get_form_class(self): elif self.filters: return modelform_factory( - self.get_report_model(), + model=self.get_report_model(), fields=self.filters, formfield_callback=default_formfield_callback, ) @@ -532,7 +577,7 @@ def __init_subclass__(cls) -> None: super().__init_subclass__() -class ListReportView(SlickReportingListViewMixin, ReportViewBase): +class ListReportView(SlickReportingListViewMixin): pass diff --git a/tests/report_generators.py b/tests/report_generators.py index 89d3172..c713a3a 100644 --- a/tests/report_generators.py +++ b/tests/report_generators.py @@ -3,7 +3,7 @@ from django.db.models import Sum, Count from django.utils.translation import gettext_lazy as _ -from slick_reporting.fields import ComputationField, PercentageToBalance +from slick_reporting.fields import ComputationField, PercentageToTotalBalance from slick_reporting.generator import ReportGenerator from .models import ( Client, @@ -193,7 +193,7 @@ class ProductTotalSalesWithPercentage(ReportGenerator): "name", "__balance__", "__balance_quantity__", - PercentageToBalance, + PercentageToTotalBalance, ] diff --git a/tests/test_generator.py b/tests/test_generator.py index 3d1401e..e0a584a 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,15 +1,14 @@ from datetime import datetime -import pytz from django.db.models import Sum from django.test import TestCase +from django.utils.translation import gettext_lazy as _ from slick_reporting.fields import ComputationField from slick_reporting.generator import ReportGenerator, ListViewReportGenerator from slick_reporting.helpers import get_foreign_keys from .models import OrderLine, ComplexSales -from django.utils.translation import gettext_lazy as _ - +from .models import SimpleSales, Client from .report_generators import ( GeneratorWithAttrAsColumn, CrosstabOnClient, @@ -21,9 +20,7 @@ CrosstabCustomQueryset, TestCountField, ) - from .tests import BaseTestData, year -from .models import SimpleSales, Client class CrosstabTests(BaseTestData, TestCase): @@ -146,8 +143,8 @@ def test_time_series_columns_inclusion(self): columns=["name", "__time_series__"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", - start_date=datetime(2020, 1, 1, tzinfo=pytz.timezone("utc")), - end_date=datetime(2020, 12, 31, tzinfo=pytz.timezone("utc")), + start_date=datetime(2020, 1, 1), + end_date=datetime(2020, 12, 31), ) self.assertEqual(len(x.get_list_display_columns()), 13) @@ -161,8 +158,8 @@ def test_time_series_patterns(self): columns=["name", "__time_series__"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", - start_date=datetime(2020, 1, 1, tzinfo=pytz.timezone("utc")), - end_date=datetime(2020, 12, 31, tzinfo=pytz.timezone("utc")), + start_date=datetime(2020, 1, 1), + end_date=datetime(2020, 12, 31), ) dates = report._get_time_series_dates() @@ -215,8 +212,8 @@ def test_time_series_columns_placeholder(self): columns=["name"], time_series_columns=["__total_quantity__"], time_series_pattern="monthly", - start_date=datetime(2020, 1, 1, tzinfo=pytz.timezone("utc")), - end_date=datetime(2020, 12, 31, tzinfo=pytz.timezone("utc")), + start_date=datetime(2020, 1, 1), + end_date=datetime(2020, 12, 31), ) self.assertEqual(len(x.get_list_display_columns()), 13) diff --git a/tests/tests.py b/tests/tests.py index cb086b9..9ad5b11 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -341,7 +341,7 @@ def test_product_total_sales_product_custom_id(self): def test_product_total_sales_with_percentage(self): report = report_generators.ProductTotalSalesWithPercentage() data = report.get_report_data() - self.assertEqual(data[2]["PercentageToBalance"], 50) + self.assertEqual(data[2]["__percent_to_total_balance__"], 50) @override_settings( SLICK_REPORTING_DEFAULT_START_DATE=datetime.datetime(2020, 1, 1),