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

Results

{% endblock %} {% block extrajs %} {{ block.super }} - - - - - - -