diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f5e429..f868727 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,10 @@ repos: rev: 23.3.0 hooks: - id: black - language_version: python3.9 \ No newline at end of file + language_version: python3.9 + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.0.287 + hooks: + - id: ruff diff --git a/CHANGELOG.md b/CHANGELOG.md index 728baf7..44c9e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [1.1.0] - +- Breaking: changed ``report_title_context_key`` default value to `report_title` +- Breaking: Renamed simple_report.html to report.html +- Breaking: Renamed ``SlickReportField`` to ``ComputationField``. SlickReportField will continue to work till next release. +- Revised and renamed js files +- Add dashboard capabilities. +- Added auto_load option to ReportView +- Unified report loading to use the report loader +- Fix issue with group_by_custom_queryset with time series +- Fix issue with No group by report +- Fix issue with traversing fields not showing up on ListViewReport +- Fix issue with date filter not being respected in ListViewReport + ## [1.0.2] - 2023-08-31 - Add a demo project for exploration and also containing all documentation code for proofing. - Revise and Enhancing Tutorial , Group by and Time series documentation. diff --git a/README.rst b/README.rst index f89f057..18e8c09 100644 --- a/README.rst +++ b/README.rst @@ -43,20 +43,18 @@ Use the package manager `pip `_ to install djang Usage ----- -So you have a model that contains data, let's call it `MySalesItems` +So we have a model `SalesTransaction` which contains typical data about a sale. +We can extract different kinds of information for that model. -You can simply use a code like this +Let's start by a "Group by" report. This will generate a report how much quantity and value was each product sold within a certain time. .. code-block:: python - # in your urls.py - path("path-to-report", TotalProductSales.as_view()) - # in views.py from django.db.models import Sum from slick_reporting.views import ReportView, Chart - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from .models import MySalesItems @@ -66,8 +64,8 @@ You can simply use a code like this group_by = "product" columns = [ "name", - SlickReportField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), - SlickReportField.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 = [ @@ -85,7 +83,12 @@ You can simply use a code like this ), ] -To get something this + # then, in urls.py + path("total-sales-report", TotalProductSales.as_view()) + + + +With this code, you will get something like this: .. image:: https://i.ibb.co/SvxTM23/Selection-294.png :target: https://i.ibb.co/SvxTM23/Selection-294.png @@ -95,29 +98,31 @@ To get something this Time Series ----------- +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. + +Example: How much was sold in value for each product monthly within a date period ? .. code-block:: python # in views.py from slick_reporting.views import ReportView - from slick_reporting.fields import SlickReportField - from .models import MySalesItems + from slick_reporting.fields import ComputationField + from .models import SalesTransaction class MonthlyProductSales(ReportView): - report_model = MySalesItems - date_field = "date_placed" + report_model = SalesTransaction + date_field = "date" group_by = "product" columns = ["name", "sku"] - # Settings for creating time series report - time_series_pattern = ( - "monthly" # or "yearly" , "weekly" , "daily" , others and custom patterns - ) + time_series_pattern = "monthly" + # or "yearly" , "weekly" , "daily" , others and custom patterns time_series_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", verbose_name=_("Sales Value"), name="value" - ) + ) # what will be calculated for each month ] chart_settings = [ @@ -128,21 +133,29 @@ Time Series title_source=["name"], plot_total=True, ), + Chart("Total Sales [Area chart]", + Chart.AREA, + data_source=["value"], + title_source=["name"], + plot_total=False, + ) ] -.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/timeseries.png?raw=true +.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/topics/_static/timeseries.png?raw=true :alt: Time Series Report :align: center Cross Tab --------- +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. .. code-block:: python # in views.py from slick_reporting.views import ReportView - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from .models import MySalesItems @@ -151,7 +164,7 @@ Cross Tab crosstab_field = "client" crosstab_ids = [1, 2, 3] crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value for")), + ComputationField.create(Sum, "value", verbose_name=_("Value for")), ] crosstab_compute_remainder = True @@ -160,11 +173,11 @@ Cross Tab # You can customize where the crosstab columns are displayed in relation to the other columns "__crosstab__", # This is the same as the Same as the calculation in the crosstab, but this one will be on the whole set. IE total value - SlickReportField.create(Sum, "value", verbose_name=_("Total Value")), + ComputationField.create(Sum, "value", verbose_name=_("Total Value")), ] -.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/report_view/_static/crosstab.png?raw=true +.. image:: https://github.com/ra-systems/django-slick-reporting/blob/develop/docs/source/topics/_static/crosstab.png?raw=true :alt: Homepage :align: center @@ -207,9 +220,13 @@ You can also use locally .. code-block:: console # clone the repo - # create a virtual environment, activate it, then + git clone https://github.com/ra-systems/django-slick-reporting.git + # create a virtual environment and activate it + python -m venv /path/to/new/virtual/environment + source /path/to/new/virtual/environment/bin/activate + cd django-slick-reporting/demo_proj - pip install requirements.txt + pip install -r requirements.txt python manage.py migrate python manage.py create_entries python manage.py runserver diff --git a/demo_proj/demo_app/admin.py b/demo_proj/demo_app/admin.py index 8c38f3f..b97a94f 100644 --- a/demo_proj/demo_app/admin.py +++ b/demo_proj/demo_app/admin.py @@ -1,3 +1,2 @@ -from django.contrib import admin # Register your models here. diff --git a/demo_proj/demo_app/helpers.py b/demo_proj/demo_app/helpers.py new file mode 100644 index 0000000..a1be416 --- /dev/null +++ b/demo_proj/demo_app/helpers.py @@ -0,0 +1,51 @@ +from django.urls import path + +from . import reports + +TUTORIAL = [ + ("product-sales", reports.ProductSales), + ("total-product-sales", reports.TotalProductSales), + ("total-product-sales-by-country", reports.TotalProductSalesByCountry), + ("monthly-product-sales", reports.MonthlyProductSales), + ("product-sales-per-client-crosstab", reports.ProductSalesPerClientCrosstab), + ("product-sales-per-country-crosstab", reports.ProductSalesPerCountryCrosstab), + ("last-10-sales", reports.LastTenSales), + ("total-product-sales-with-custom-form", reports.TotalProductSalesWithCustomForm), + +] + +GROUP_BY = [ + ("group-by-report", reports.GroupByReport), + ("group-by-traversing-field", reports.GroupByTraversingFieldReport), + ("group-by-custom-queryset", reports.GroupByCustomQueryset), + ("no-group-by", reports.NoGroupByReport), +] + +TIME_SERIES = [ + ("time-series-report", reports.TimeSeriesReport), + ("time-series-with-selector", reports.TimeSeriesReportWithSelector), + ("time-series-with-custom-dates", reports.TimeSeriesReportWithCustomDates), + ("time-series-with-custom-dates-and-title", reports.TimeSeriesReportWithCustomDatesAndCustomTitle), + ("time-series-without-group-by", reports.TimeSeriesWithoutGroupBy), + ('time-series-with-group-by-custom-queryset', reports.TimeSeriesReportWithCustomGroupByQueryset), +] + +CROSSTAB = [ + ("crosstab-report", reports.CrosstabReport), + ("crosstab-report-with-ids", reports.CrosstabWithIds), + ("crosstab-report-traversing-field", reports.CrosstabWithTraversingField), + ("crosstab-report-custom-filter", reports.CrosstabWithIdsCustomFilter), + ("crosstab-report-custom-verbose-name", reports.CrossTabReportWithCustomVerboseName), + ("crosstab-report-custom-verbose-name-2", reports.CrossTabReportWithCustomVerboseNameCustomFilter), + ("crosstab-report-with-time-series", reports.CrossTabWithTimeSeries), + +] + + +def get_urls_patterns(): + urls = [] + for name, report in TUTORIAL + GROUP_BY + TIME_SERIES + CROSSTAB: + urls.append(path(f"{name}/", report.as_view(), name=name)) + return urls + + diff --git a/demo_proj/demo_app/management/commands/create_entries.py b/demo_proj/demo_app/management/commands/create_entries.py index f9f3589..bcef437 100644 --- a/demo_proj/demo_app/management/commands/create_entries.py +++ b/demo_proj/demo_app/management/commands/create_entries.py @@ -21,10 +21,6 @@ class Command(BaseCommand): def handle(self, *args, **options): # create clients - models_list = [ - Client, - Product, - ] client_countries = [ "US", "DE", @@ -48,7 +44,7 @@ def handle(self, *args, **options): for i in range(10): User.objects.create_user(username=f"user {i}", password="password") - users_id = list(User.objects.values_list("id", flat=True)) + list(User.objects.values_list("id", flat=True)) for i in range(1, 4): ProductCategory.objects.create(name=f"Product Category {i}") diff --git a/demo_proj/demo_app/migrations/0004_client_country_product_sku.py b/demo_proj/demo_app/migrations/0004_client_country_product_sku.py new file mode 100644 index 0000000..82c95cc --- /dev/null +++ b/demo_proj/demo_app/migrations/0004_client_country_product_sku.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.4 on 2023-08-30 08:38 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('demo_app', '0003_product_category'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='country', + field=models.CharField(default='US', max_length=255, verbose_name='Country'), + ), + migrations.AddField( + model_name='product', + name='sku', + field=models.CharField(default=uuid.uuid4, max_length=255, verbose_name='SKU'), + ), + ] diff --git a/demo_proj/demo_app/migrations/0005_product_size.py b/demo_proj/demo_app/migrations/0005_product_size.py new file mode 100644 index 0000000..939b5e0 --- /dev/null +++ b/demo_proj/demo_app/migrations/0005_product_size.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.4 on 2023-08-30 11:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('demo_app', '0004_client_country_product_sku'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='size', + field=models.CharField(default='Medium', max_length=100, verbose_name='Product Category'), + ), + ] diff --git a/demo_proj/demo_app/migrations/0006_productcategory_remove_product_category_and_more.py b/demo_proj/demo_app/migrations/0006_productcategory_remove_product_category_and_more.py new file mode 100644 index 0000000..7e62f16 --- /dev/null +++ b/demo_proj/demo_app/migrations/0006_productcategory_remove_product_category_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.4 on 2023-08-30 17:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('demo_app', '0005_product_size'), + ] + + operations = [ + migrations.CreateModel( + name='ProductCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Product Category Name')), + ], + ), + migrations.RemoveField( + model_name='product', + name='category', + ), + migrations.AlterField( + model_name='product', + name='size', + field=models.CharField(default='Medium', max_length=100, verbose_name='Size'), + ), + migrations.AddField( + model_name='product', + name='product_category', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='demo_app.productcategory'), + ), + ] diff --git a/demo_proj/demo_app/reports.py b/demo_proj/demo_app/reports.py index a6d9233..fe2d24f 100644 --- a/demo_proj/demo_app/reports.py +++ b/demo_proj/demo_app/reports.py @@ -1,20 +1,24 @@ import datetime +from django.db.models import Sum, Q +from django.utils.translation import gettext_lazy as _ + +from slick_reporting.fields import ComputationField +from slick_reporting.views import ListReportView from slick_reporting.views import ReportView, Chart -from slick_reporting.fields import SlickReportField -from .models import SalesTransaction from .forms import TotalSalesFilterForm -from django.db.models import Sum +from .models import SalesTransaction, Product class ProductSales(ReportView): + report_title = _("Product Sales") report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", - SlickReportField.create( + ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] @@ -31,14 +35,16 @@ class ProductSales(ReportView): class TotalProductSales(ReportView): + report_title = _("Product Sales Quantity and Value [no auto load]") report_model = SalesTransaction date_field = "date" group_by = "product" columns = [ "name", - SlickReportField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), - SlickReportField.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 $"), ] + auto_load = False # require the user to press the filter, useful if the report is resource demanding chart_settings = [ Chart( @@ -57,12 +63,14 @@ class TotalProductSales(ReportView): class TotalProductSalesByCountry(ReportView): + report_title = _("Product Sales by Country") + report_model = SalesTransaction date_field = "date" group_by = "client__country" # notice the double underscore columns = [ "client__country", - SlickReportField.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 = [ @@ -75,10 +83,7 @@ class TotalProductSalesByCountry(ReportView): ] -from django.utils.translation import gettext_lazy as _ - - -class SumValueComputationField(SlickReportField): +class SumValueComputationField(ComputationField): computation_method = Sum computation_field = "value" verbose_name = _("Sales Value") @@ -86,6 +91,7 @@ class SumValueComputationField(SlickReportField): class MonthlyProductSales(ReportView): + report_title = _("Product Sales Monthly") report_model = SalesTransaction date_field = "date" group_by = "product" @@ -114,6 +120,7 @@ class MonthlyProductSales(ReportView): class ProductSalesPerClientCrosstab(ReportView): + report_title = _("Product Sales Per Client Crosstab") report_model = SalesTransaction date_field = "date" group_by = "product" @@ -135,6 +142,7 @@ class ProductSalesPerClientCrosstab(ReportView): class ProductSalesPerCountryCrosstab(ReportView): + report_title = _("Product Sales Per Country Crosstab") report_model = SalesTransaction date_field = "date" group_by = "product" @@ -154,9 +162,6 @@ class ProductSalesPerCountryCrosstab(ReportView): ] -from slick_reporting.views import ListReportView - - class LastTenSales(ListReportView): report_model = SalesTransaction report_title = "Last 10 sales" @@ -179,8 +184,8 @@ class TotalProductSalesWithCustomForm(TotalProductSales): columns = [ "name", "size", - SlickReportField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), - SlickReportField.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 $"), ] @@ -192,7 +197,7 @@ class GroupByReport(ReportView): columns = [ "name", - SlickReportField.create( + ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] @@ -227,7 +232,7 @@ class GroupByCustomQueryset(ReportView): columns = [ "__index__", - SlickReportField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), + ComputationField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), ] chart_settings = [ @@ -257,7 +262,21 @@ def format_row(self, row_obj): return row_obj +class NoGroupByReport(ReportView): + report_model = SalesTransaction + report_title = _("No-Group-By Report") + date_field = "date" + group_by = "" + + columns = [ + ComputationField.create( + method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + ), + ] + + class TimeSeriesReport(ReportView): + report_title = _("Time Series Report") report_model = SalesTransaction group_by = "client" time_series_pattern = "monthly" @@ -265,7 +284,7 @@ class TimeSeriesReport(ReportView): date_field = "date" time_series_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Sales For ")), + ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), ] # These columns will be calculated for each period in the time series. @@ -274,7 +293,7 @@ class TimeSeriesReport(ReportView): "__time_series__", # placeholder for the generated time series columns - SlickReportField.create(Sum, "value", verbose_name=_("Total Sales")), + ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), # This is the same as the time_series_columns, but this one will be on the whole set ] @@ -331,9 +350,55 @@ class TimeSeriesReportWithCustomDates(TimeSeriesReport): ) -class SumOfFieldValue(SlickReportField): +class TimeSeriesReportWithCustomGroupByQueryset(ReportView): + report_title = _("Time Series Report") + report_model = SalesTransaction + group_by_custom_querysets = ( + SalesTransaction.objects.filter(client__country='US'), + SalesTransaction.objects.filter(client__country__in=['RS', 'DE']), + ) + + time_series_pattern = "monthly" + + date_field = "date" + time_series_columns = [ + ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), + # "__total__" + ] + + columns = [ + "__index__", + "__time_series__", + # placeholder for the generated time series columns + + ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), + # This is the same as the time_series_columns, but this one will be on the whole set + + ] + + chart_settings = [ + Chart("Client Sales", + Chart.BAR, + data_source=["sum__value"], + title_source=["__index__"], + ), + Chart("Total Sales [Pie]", + Chart.PIE, + data_source=["sum__value"], + title_source=["__index__"], + plot_total=True, + ), + Chart("Total Sales [Area chart]", + Chart.AREA, + data_source=["sum__value"], + title_source=["name"], + ) + ] + + +class SumOfFieldValue(ComputationField): # A custom computation Field identical to the one created like this - # Similar to `SlickReportField.create(Sum, "value", verbose_name=_("Total Sales"))` + # Similar to `ComputationField.create(Sum, "value", verbose_name=_("Total Sales"))` calculation_method = Sum calculation_field = "value" @@ -352,7 +417,7 @@ class TimeSeriesReportWithCustomDatesAndCustomTitle(TimeSeriesReportWithCustomDa report_title = _("Time Series Report With Custom Dates and custom Title") time_series_columns = [ - SumOfFieldValue, # Use our newly created SlickReportField with the custom time series verbose name + SumOfFieldValue, # Use our newly created ComputationField with the custom time series verbose name ] chart_settings = [ @@ -368,3 +433,133 @@ class TimeSeriesReportWithCustomDatesAndCustomTitle(TimeSeriesReportWithCustomDa plot_total=True, ), ] + + +class TimeSeriesWithoutGroupBy(ReportView): + report_title = _("Time Series without a group by") + report_model = SalesTransaction + time_series_pattern = "monthly" + date_field = "date" + time_series_columns = [ + ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), + ] + + columns = [ + "__time_series__", + ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), + ] + + 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"], + ), + ] + + +class CrosstabReport(ReportView): + report_title = _("Cross tab Report") + report_model = SalesTransaction + group_by = "client" + date_field = "date" + + columns = [ + "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. + ] + + crosstab_field = "product" + crosstab_columns = [ + ComputationField.create(Sum, "value", verbose_name=_("Value")), + ] + + +class CrosstabWithTraversingField(CrosstabReport): + report_title = _("Cross tab Report With Traversing Field") + crosstab_field = "product__size" + + +class CrosstabWithIds(CrosstabReport): + report_title = _("Cross tab Report With Pre-set Ids") + + def get_crosstab_ids(self): + return [Product.objects.first().pk, Product.objects.last().pk] + + +class CrosstabWithIdsCustomFilter(CrosstabReport): + report_title = _("Crosstab with Custom Filters") + crosstab_ids_custom_filters = [ + (~Q(product__size__in=["extra_big", "big"]), dict()), + + (None, dict(product__size__in=["extra_big", "big"])), + ] + # Note: + # if crosstab_ids_custom_filters is set, these settings has NO EFFECT + # crosstab_field = "client" + # crosstab_ids = [1, 2] + # crosstab_compute_remainder = True + + +class CustomCrossTabTotalField(ComputationField): + calculation_field = "value" + calculation_method = Sum + verbose_name = _("Sales for") + name = "sum__value" + + @classmethod + def get_crosstab_field_verbose_name(cls, model, id): + if id == "----": # 4 dashes: the remainder column + return _("Rest of Products") + + name = Product.objects.get(pk=id).name + return f"{cls.verbose_name} {name}" + + +class CrossTabReportWithCustomVerboseName(CrosstabReport): + report_title = _("Crosstab with customized verbose name") + crosstab_columns = [ + CustomCrossTabTotalField + ] + + +class CustomCrossTabTotalField2(CustomCrossTabTotalField): + @classmethod + def get_crosstab_field_verbose_name(cls, model, id): + print(model, id) + if id == 0: + return f"{cls.verbose_name} Big and Extra Big" + return f"{cls.verbose_name} all other sizes" + + @classmethod + def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): + print("time series verbose name") + return super().get_time_series_field_verbose_name(date_period, index, dates, pattern) + + +class CrossTabReportWithCustomVerboseNameCustomFilter(CrosstabWithIdsCustomFilter): + report_title = _("Crosstab customized verbose name with custom filter") + + crosstab_columns = [ + CustomCrossTabTotalField2 + ] + + +class CrossTabWithTimeSeries(CrossTabReportWithCustomVerboseNameCustomFilter): + report_title = _("Crosstab with time series") + time_series_pattern = "monthly" + + columns = [ + "name", + "__time_series__" + ] diff --git a/demo_proj/demo_app/templatetags/__init__.py b/demo_proj/demo_app/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo_proj/demo_app/templatetags/slick_reporting_demo_tags.py b/demo_proj/demo_app/templatetags/slick_reporting_demo_tags.py new file mode 100644 index 0000000..e78bea5 --- /dev/null +++ b/demo_proj/demo_app/templatetags/slick_reporting_demo_tags.py @@ -0,0 +1,47 @@ +from django import template +from django.urls import reverse +from django.utils.html import format_html +from django.utils.safestring import mark_safe + +register = template.Library() + + +def get_section(section): + from ..helpers import TUTORIAL, GROUP_BY, TIME_SERIES, CROSSTAB + to_use = [] + + if section == "tutorial": + to_use = TUTORIAL + elif section == "group_by": + to_use = GROUP_BY + elif section == "timeseries": + to_use = TIME_SERIES + elif section == "crosstab": + to_use = CROSSTAB + return to_use + + +@register.simple_tag(takes_context=True) +def get_menu(context, section): + request = context['request'] + to_use = get_section(section) + menu = [] + for link, report in to_use: + is_active = "active" if f"/{link}/" in request.path else "" + + menu.append(format_html( + '{text}', active=is_active, + href=reverse(link), text=report.report_title or link) + ) + + return mark_safe("".join(menu)) + + +@register.simple_tag(takes_context=True) +def should_show(context, section): + request = context["request"] + to_use = get_section(section) + for link, report in to_use: + if f"/{link}/" in request.path: + return "show" + return "" diff --git a/demo_proj/demo_app/tests.py b/demo_proj/demo_app/tests.py index 7ce503c..4929020 100644 --- a/demo_proj/demo_app/tests.py +++ b/demo_proj/demo_app/tests.py @@ -1,3 +1,2 @@ -from django.test import TestCase # Create your tests here. diff --git a/demo_proj/demo_app/views.py b/demo_proj/demo_app/views.py index 91ea44a..f9d0610 100644 --- a/demo_proj/demo_app/views.py +++ b/demo_proj/demo_app/views.py @@ -1,3 +1,11 @@ -from django.shortcuts import render +from django.views.generic import TemplateView + # Create your views here. + +class HomeView(TemplateView): + template_name = "home.html" + + +class Dashboard(TemplateView): + template_name = "dashboard.html" diff --git a/demo_proj/demo_proj/settings.py b/demo_proj/demo_proj/settings.py index d3b6f9b..a447da6 100644 --- a/demo_proj/demo_proj/settings.py +++ b/demo_proj/demo_proj/settings.py @@ -9,7 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ - +import os from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -40,7 +40,7 @@ "demo_app", "crispy_forms", - "crispy_bootstrap4", + "crispy_bootstrap5", "slick_reporting", # "slick_reporting.dashboards", ] @@ -60,7 +60,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [os.path.join(BASE_DIR, "templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -128,4 +128,5 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CRISPY_TEMPLATE_PACK = "bootstrap4" \ No newline at end of file +CRISPY_TEMPLATE_PACK = "bootstrap5" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" diff --git a/demo_proj/demo_proj/urls.py b/demo_proj/demo_proj/urls.py index d1026a1..d922256 100644 --- a/demo_proj/demo_proj/urls.py +++ b/demo_proj/demo_proj/urls.py @@ -22,8 +22,15 @@ GroupByReport, GroupByTraversingFieldReport, GroupByCustomQueryset, TimeSeriesReport from demo_app import reports +from demo_app import views urlpatterns = [ + + path("", views.HomeView.as_view(), name="home"), + path("dashboard/", views.Dashboard.as_view(), name="dashboard"), + + + # tutorial path("product-sales/", ProductSales.as_view(), name="product-sales"), path("total-product-sales/", TotalProductSales.as_view(), name="total-product-sales"), path("total-product-sales-by-country/", TotalProductSalesByCountry.as_view(), @@ -34,13 +41,16 @@ path("product-sales-per-country-crosstab/", ProductSalesPerCountryCrosstab.as_view(), name="product-sales-per-country-crosstab"), path("last-10-sales/", LastTenSales.as_view(), name="last-10-sales"), - path("total-product-sales-with-custom-form/", TotalProductSalesWithCustomForm.as_view(), name="total-product-sales-with-custom-form"), + # Group by path("group-by-report/", GroupByReport.as_view(), name="group-by-report"), path("group-by-traversing-field/", GroupByTraversingFieldReport.as_view(), name="group-by-traversing-field"), path("group-by-custom-queryset/", GroupByCustomQueryset.as_view(), name="group-by-custom-queryset"), + path("no-group-by/", reports.NoGroupByReport.as_view(), name="no-group-by"), + + # Time Series path("time-series-report/", TimeSeriesReport.as_view(), name="time-series-report"), path("time-series-with-selector/", reports.TimeSeriesReportWithSelector.as_view(), name="time-series-with-selector"), @@ -48,6 +58,26 @@ name="time-series-with-custom-dates"), path("time-series-with-custom-dates-and-title/", reports.TimeSeriesReportWithCustomDatesAndCustomTitle.as_view(), name="time-series-with-custom-dates-and-title"), + path("time-series-without-group-by/", reports.TimeSeriesWithoutGroupBy.as_view(), + name="time-series-without-group-by"), + path("time-series-with-group-by-custom-queryset/", reports.TimeSeriesReportWithCustomGroupByQueryset.as_view(), + name="time-series-with-group-by-custom-queryset"), + + # Crosstab + path("crosstab-report/", reports.CrosstabReport.as_view(), name="crosstab-report"), + path("crosstab-report-with-ids/", reports.CrosstabWithIds.as_view(), name="crosstab-report-with-ids"), + path("crosstab-report-traversing-field/", reports.CrosstabWithTraversingField.as_view(), + name="crosstab-report-traversing-field"), + path("crosstab-report-custom-filter/", reports.CrosstabWithIdsCustomFilter.as_view(), + name="crosstab-report-custom-filter"), + path("crosstab-report-custom-verbose-name/", reports.CrossTabReportWithCustomVerboseName.as_view(), + name="crosstab-report-custom-verbose-name"), + path("crosstab-report-custom-verbose-name-2/", reports.CrossTabReportWithCustomVerboseNameCustomFilter.as_view(), + name="crosstab-report-custom-verbose-name-2"), + path("crosstab-report-with-time-series/", reports.CrossTabWithTimeSeries.as_view(), + name="crosstab-report-with-time-series"), + + path("admin/", admin.site.urls), ] diff --git a/demo_proj/demo_proj/wsgi.py b/demo_proj/demo_proj/wsgi.py index 4156325..2a2cbda 100644 --- a/demo_proj/demo_proj/wsgi.py +++ b/demo_proj/demo_proj/wsgi.py @@ -7,10 +7,12 @@ https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ """ -import os +import os, sys from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo_proj.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo_proj.settings_production") +BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "../") +sys.path.append(os.path.abspath(BASE_DIR)) application = get_wsgi_application() diff --git a/demo_proj/requirements.txt b/demo_proj/requirements.txt index 5da9de1..8766e2e 100644 --- a/demo_proj/requirements.txt +++ b/demo_proj/requirements.txt @@ -2,4 +2,4 @@ django>=4.2 python-dateutil>=2.8.1 simplejson django-crispy-forms -crispy-bootstrap4 \ No newline at end of file +crispy-bootstrap5 diff --git a/demo_proj/templates/base.html b/demo_proj/templates/base.html new file mode 100644 index 0000000..d98ad45 --- /dev/null +++ b/demo_proj/templates/base.html @@ -0,0 +1,97 @@ + + + + + + {% block meta_page_title %}{{ report_title }}{% endblock %} + + + + + + + + + + + + + +{# #} +
+ + +
+ +
+
+ {% block content %} + {% endblock %} +
+ +
+
+
+ + + +{# #} +{# #} + + +{% block extrajs %} +{% endblock %} + + \ No newline at end of file diff --git a/demo_proj/templates/dashboard.html b/demo_proj/templates/dashboard.html new file mode 100644 index 0000000..9a7999d --- /dev/null +++ b/demo_proj/templates/dashboard.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% load slick_reporting_tags %} +{% 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 %} +
+
+ {% get_widget_from_url url_name="product-sales" %} +
+
+ {% get_widget_from_url url_name="total-product-sales" title="Widget custom title" %} +
+ +
+ {% get_widget_from_url url_name="total-product-sales" chart_id=1 title="Custom default Chart" %} +
+ +
+ {% get_widget_from_url url_name="total-product-sales" display_table=False title="No table, Chart Only" %} +
+ +
+ {% get_widget_from_url url_name="total-product-sales" display_chart=False title="Table only, no chart" %} +
+ +
+ {% get_widget_from_url url_name="total-product-sales" display_table=False display_chart_selector=False title="No Chart Selector, only the assigned one" %} +
+ +
+ {% get_widget_from_url url_name="total-product-sales" success_callback="custom_js_callback" title="Custom Js Handler and template" template_name="widget_template_with_pre.html" %} +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/demo_proj/templates/home.html b/demo_proj/templates/home.html new file mode 100644 index 0000000..baaa104 --- /dev/null +++ b/demo_proj/templates/home.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Welcome to Django Slick Reporting

+

Powerful and Efficient reporting engine with Charting capabilities. + Allowing you to create professional analytics that are customizable and efficient.

+

+ Start walk through + Github +

+
+
+ +
+ +
+
+

Powerful

+

Effortlessly create Simple, Grouped, Time series and Crosstab reports in a handful of code lines. + You can also create your Custom Calculation easily, which will be integrated with the above reports + types

+

+{# This#} +{# site on Github »#} + Begin Walk through +

+
+
+

Chart Wrappers

+

Slick reporting comes with Highcharts and Charts.js wrappers to transform the generated data into + attractive charts in handfule of lines

+

You can check Django Slick Reporting documentation for more in depth information

+

Read + the docs »

+
+
+

Open source

+

Optimized for speed. You can also check this same website and generate more data and test this package on million on records yourself + +

This + site on Github »

+ +{# Star#} +

+{#

View details »

#} +
+
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/demo_proj/templates/menu.html b/demo_proj/templates/menu.html new file mode 100644 index 0000000..a939f20 --- /dev/null +++ b/demo_proj/templates/menu.html @@ -0,0 +1,166 @@ +{% load slick_reporting_demo_tags %} + + \ No newline at end of file diff --git a/demo_proj/templates/slick_reporting/base.html b/demo_proj/templates/slick_reporting/base.html new file mode 100644 index 0000000..6932026 --- /dev/null +++ b/demo_proj/templates/slick_reporting/base.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + + +{% block page_title %} + {{ report_title }} +{% endblock %} +{% block meta_page_title %} + {{ report_title }} + +{% endblock %} + +{% block extrajs %} + {{ block.super }} + + {% include "slick_reporting/js_resources.html" %} + +{% endblock %} \ No newline at end of file diff --git a/demo_proj/templates/widget_template_with_pre.html b/demo_proj/templates/widget_template_with_pre.html new file mode 100644 index 0000000..f4f19f7 --- /dev/null +++ b/demo_proj/templates/widget_template_with_pre.html @@ -0,0 +1,6 @@ +{% extends "slick_reporting/widget_template.html" %} +{% block widget_content %} +
+

+    
+{% endblock %} \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 816af2a..49882cc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -37,7 +37,7 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene # in views.py from slick_reporting.views import ReportView, Chart - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from .models import MySalesItems from django.db.models import Sum @@ -50,7 +50,7 @@ You can start by using ``ReportView`` which is a subclass of ``django.views.gene columns = [ "title", - SlickReportField.create( + ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $" ), ] @@ -89,8 +89,8 @@ Next step :ref:`tutorial` concept tutorial - howto/index topics/index + howto/index charts ref/index diff --git a/docs/source/ref/computation_field.rst b/docs/source/ref/computation_field.rst index 1c5a157..2d6ce38 100644 --- a/docs/source/ref/computation_field.rst +++ b/docs/source/ref/computation_field.rst @@ -16,12 +16,12 @@ Let's see how it's written in `slick_reporting.fields` .. code-block:: python - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from slick_reporting.decorators import report_field_register @report_field_register - class TotalQTYReportField(SlickReportField): + class TotalQTYReportField(ComputationField): # The name to use when using this field in the generator name = '__total_quantity__' @@ -43,7 +43,7 @@ If you want AVG to the field `price` then the ReportField would look like this from django.db.models import Avg @report_field_register - class TotalQTYReportField(SlickReportField): + class TotalQTYReportField(ComputationField): name = '__avg_price__' calculation_field = 'price' @@ -101,10 +101,10 @@ Two side calculation # Document how a single field can be computed like a debit and credit. -SlickReportField API +ComputationField API -------------------- -.. autoclass:: slick_reporting.fields.SlickReportField +.. autoclass:: slick_reporting.fields.ComputationField .. autoattribute:: name .. autoattribute:: calculation_field diff --git a/docs/source/ref/view_options.rst b/docs/source/ref/view_options.rst index a98ce92..d96a98d 100644 --- a/docs/source/ref/view_options.rst +++ b/docs/source/ref/view_options.rst @@ -46,14 +46,14 @@ Below is the list of general options that is used across all types of reports. .. code-block:: python - class MyTotalReportField(SlickReportField): + class MyTotalReportField(ComputationField): pass class MyReport(ReportView): columns = [ # a computation field created on the fly - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value"), + ComputationField.create(Sum, "value", verbose_name=_("Value"), name="value"), # A computation Field class MyTotalReportField, @@ -95,7 +95,7 @@ Below is the list of general options that is used across all types of reports. report_model = MySales group_by = None columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value"), name="value") + ComputationField.create(Sum, "value", verbose_name=_("Value"), name="value") ] Above code will return the calculated sum of all values in the report_model / queryset diff --git a/docs/source/topics/crosstab_options.rst b/docs/source/topics/crosstab_options.rst index 7e6e26a..14558dc 100644 --- a/docs/source/topics/crosstab_options.rst +++ b/docs/source/topics/crosstab_options.rst @@ -5,7 +5,10 @@ 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: + +General use case +---------------- +Here is a general use case: .. code-block:: python @@ -14,129 +17,126 @@ Here is a simple example of a crosstab report: from slick_reporting.views import ReportView - class MyCrosstabReport(ReportView): - group_by = "product" - crosstab_field = "client" - # the column you want to make a crosstab on, can be a foreign key or a choice field - - crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - ] - - crosstab_ids = [ - 1, - 2, - ] # a list of ids of the crosstab field you want to use. This will be passed on by the filter form, or , if set here, values here will be used. - # 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 + class CrosstabReport(ReportView): + report_title = _("Cross tab Report") + report_model = SalesTransaction + group_by = "client" + date_field = "date" columns = [ "name", - "sku", "__crosstab__", # You can customize where the crosstab columns are displayed in relation to the other columns - SlickReportField.create(Sum, "value", verbose_name=_("Total Value")), - # This is the same as the Same as the calculation in the crosstab, but this one will be on the whole set. IE total value + + 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. ] + crosstab_field = "product" + crosstab_columns = [ + ComputationField.create(Sum, "value", verbose_name=_("Value")), + ] -Customizing the crosstab ids ----------------------------- +Crosstab on a Traversing Field +------------------------------ +You can also crosstab on a traversing field. In the example below we extend the previous crosstab report to be on the product sizes -For more fine tuned report, You can customize the ids of the crosstab report by suppling a list of tuples to the ``crosstab_ids_custom_filters`` attribute. -the tuple should have 2 items, the first is a Q object(s) -if any- , and the second is a dict of kwargs filters that will be passed to the filter method of the ``report_model``. +.. code-block:: python -Example: + class CrosstabWithTraversingField(CrosstabReport): + crosstab_field = "product__size" -.. code-block:: python - from .models import MySales +Customizing the crosstab ids +---------------------------- +You can set the default ids that you want to crosstab on, so the initial report, ie without user setting anything, comes out with the values you want +.. code-block:: python - class MyCrosstabReport(ReportView): + class CrosstabWithIds(CrosstabReport): + def get_crosstab_ids(self): + return [Product.objects.first().pk, Product.objects.last().pk] - date_field = "date" - group_by = "product" - report_model = MySales - crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - ] +Customizing the Crosstab Filter +------------------------------- - crosstab_ids_custom_filters = [ - ( - ~Q(special_field="something"), - dict(flag="sales"), - ), # special_field and flag are fields on the report_model . - (None, dict(flag="sales-return")), - ] +For more fine tuned report, You can customize the crosstab report by supplying a list of tuples to the ``crosstab_ids_custom_filters`` attribute. +The tuple should have 2 items, the first is a list of Q object(s) -if any- , and the second is a dict of kwargs filters . Both will be passed to the filter method of the ``report_model``. - # These settings has NO EFFECT if crosstab_ids_custom_filters is set - crosstab_field = "client" - crosstab_ids = [1, 2] - crosstab_compute_remainder = True +Example: + +.. code-block:: python + class CrosstabWithIdsCustomFilter(CrosstabReport): + crosstab_ids_custom_filters = [ + (~Q(product__size__in=["extra_big", "big"]), dict()), + (None, dict(product__size__in=["extra_big", "big"])), + ] + # Note: + # if crosstab_ids_custom_filters is set, these settings has NO EFFECT + # crosstab_field = "client" + # crosstab_ids = [1, 2] + # crosstab_compute_remainder = True -Having Time Series Crosstab Reports ------------------------------------ -You can have a crosstab report in a time series by setting the :ref:`time_series_options` in addition to the crosstab options. Customizing the verbose name of the crosstab columns ---------------------------------------------------- -You can customize the verbose name of the crosstab columns by Customizing the ``ReportField`` and setting the ``crosstab_field_verbose_name`` attribute to your custom class. +Similar to what we did in customizing the verbose name of the computation field for the time series, +Here, We also can customize the verbose name of the crosstab columns by Subclass ``ComputationField`` and setting the ``crosstab_field_verbose_name`` attribute on 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". +Let's see two examples on how we can customize the verbose name. -.. code-block:: python +Example 1: On a "regular" crosstab report - class CustomCrossTabTotalField(SlickReportField): +.. code-block:: python + class CustomCrossTabTotalField(ComputationField): calculation_field = "value" calculation_method = Sum - verbose_name = _("Total Value") + verbose_name = _("Sales for") + name = "sum__value" @classmethod def get_crosstab_field_verbose_name(cls, model, id): - from .models import Client + if id == "----": # 4 dashes: the remainder column + return _("Rest of Products") - 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) + name = Product.objects.get(pk=id).name return f"{cls.verbose_name} {name}" -Example -------- + class CrossTabReportWithCustomVerboseName(CrosstabReport): + crosstab_columns = [ + CustomCrossTabTotalField + ] -.. code-block:: python +Example 2: On the ``crosstab_ids_custom_filters`` one - from .models import MySales +.. code-block:: python + class CustomCrossTabTotalField2(CustomCrossTabTotalField): - class MyCrosstabReport(ReportView): + @classmethod + def get_crosstab_field_verbose_name(cls, model, id): + if id == 0: + return f"{cls.verbose_name} Big and Extra Big" + return f"{cls.verbose_name} all other sizes" - date_field = "date" - group_by = "product" - report_model = MySales - crosstab_field = "client" - crosstab_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Value")), - ] + class CrossTabReportWithCustomVerboseNameCustomFilter(CrosstabWithIdsCustomFilter): + crosstab_columns = [ + CustomCrossTabTotalField2 + ] - crosstab_ids = [1, 2] # either set here via the filter form - crosstab_compute_remainder = True -The above code would return a result like this: +Example +------- .. image:: _static/crosstab.png :width: 800 diff --git a/docs/source/topics/group_by_report.rst b/docs/source/topics/group_by_report.rst index 8fa53e1..81e7c63 100644 --- a/docs/source/topics/group_by_report.rst +++ b/docs/source/topics/group_by_report.rst @@ -22,7 +22,7 @@ Example: columns = [ "name", - SlickReportField.create( + ComputationField.create( method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, ), ] @@ -89,7 +89,7 @@ Example: columns = [ "__index__", - SlickReportField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), + ComputationField.create(Sum, "value", verbose_name=_("Total Sold $"), name="value"), ] chart_settings = [ @@ -126,3 +126,26 @@ It just hold the index of the row in the group. its verbose name (ie the one on the table header) can be customized via ``group_by_custom_querysets_column_verbose_name`` You can then customize the *value* of the __index__ column via ``format_row`` hook + +The No Group By +--------------- +Sometimes you want to get some calculations done on the whole report_model, without a group_by. +You can do that by having the calculation fields you need in the columns, and leave out the group by. + +Example: + +.. code-block:: python + + class NoGroupByReport(ReportView): + report_model = SalesTransaction + report_title = _("No-Group-By Report [WIP]") + date_field = "date" + group_by = "" + + columns = [ + ComputationField.create( + method=Sum, field="value", name="value__sum", verbose_name="Total sold $", is_summable=True, + ), + ] + +This report will give one number, the sum of all the values in the ``value`` field of the ``SalesTransaction`` model, within a period. diff --git a/docs/source/topics/time_series_options.rst b/docs/source/topics/time_series_options.rst index 5adf363..734e3ec 100644 --- a/docs/source/topics/time_series_options.rst +++ b/docs/source/topics/time_series_options.rst @@ -6,7 +6,10 @@ 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 look at the Typical use case +General use case +---------------- + +Here is a quick look at the general use case .. code-block:: python @@ -20,13 +23,13 @@ Here is a quick look at the Typical use case group_by = "client" time_series_pattern = "monthly" - # options are : "daily", "weekly", "bi-weekly", "monthly", "quarterly", "semiannually", "annually" and "custom" + # options are: "daily", "weekly", "bi-weekly", "monthly", "quarterly", "semiannually", "annually" and "custom" date_field = "date" # These columns will be calculated for each period in the time series. time_series_columns = [ - SlickReportField.create(Sum, "value", verbose_name=_("Sales For Month")), + ComputationField.create(Sum, "value", verbose_name=_("Sales For Month")), ] columns = [ @@ -34,7 +37,7 @@ Here is a quick look at the Typical use case "__time_series__", # This is the same as the time_series_columns, but this one will be on the whole set - SlickReportField.create(Sum, "value", verbose_name=_("Total Sales")), + ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), ] @@ -113,7 +116,7 @@ Let's see how you can do that, inheriting from teh same Time series we did first Customize the Computation Field label ------------------------------------- Maybe you want to customize how the title of the time series computation field. -For this you want to Subclass ``SlickReportField``, where you can customize +For this you want to Subclass ``ComputationField``, where you can customize how the title is created and use it in the time_series_column instead of the one created on the fly. Example: @@ -121,9 +124,9 @@ Example: .. code-block:: python - class SumOfFieldValue(SlickReportField): + class SumOfFieldValue(ComputationField): # A custom computation Field identical to the one created like this - # Similar to `SlickReportField.create(Sum, "value", verbose_name=_("Total Sales"))` + # Similar to `ComputationField.create(Sum, "value", verbose_name=_("Total Sales"))` calculation_method = Sum calculation_field = "value" @@ -142,7 +145,7 @@ Example: report_title = _("Time Series Report With Custom Dates and custom Title") time_series_columns = [ - SumOfFieldValue, # Use our newly created SlickReportField with the custom time series verbose name + SumOfFieldValue, # Use our newly created ComputationField with the custom time series verbose name ] chart_settings = [ @@ -160,6 +163,44 @@ Example: ] +Time Series without a group by +------------------------------ +Maybe you want to get the time series calculated on the whole set, without grouping by anything. +You can do that by omitting the `group_by` attribute, and having only time series (or other computation fields) columns. + +Example: + +.. code-block:: python + + class TimeSeriesWithoutGroupBy(ReportView): + report_title = _("Time Series without a group by") + report_model = SalesTransaction + time_series_pattern = "monthly" + date_field = "date" + time_series_columns = [ + ComputationField.create(Sum, "value", verbose_name=_("Sales For ")), + ] + + columns = [ + "__time_series__", + ComputationField.create(Sum, "value", verbose_name=_("Total Sales")), + ] + + 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"], + ), + ] + + + .. _time_series_options: @@ -185,10 +226,10 @@ Time Series Options class MyReport(ReportView): time_series_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", verbose_name=_("Value"), is_summable=True, name="sum__value" ), - SlickReportField.create( + ComputationField.create( Avg, "Price", verbose_name=_("Avg Price"), is_summable=False ), ] diff --git a/docs/source/topics/widgets.rst b/docs/source/topics/widgets.rst new file mode 100644 index 0000000..66f3312 --- /dev/null +++ b/docs/source/topics/widgets.rst @@ -0,0 +1,132 @@ +.. _widgets: + +Widgets +======= +You can use the report data 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 + +.. code-block:: html+django + + {% load static slick_reporting_tags %} + + {# make sure to have the js_resources added to your page #} + {% block extrajs %} + {% include "slick_reporting/js_resources.html" %} + {% endblock %} + + {% block content %} + {% get_widget_from_url url_name="product-sales" %} + {% endblock %} + +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. + chart_id is, by default, its index in the ``chart_settings`` list. +* display_table: bool, If the widget should show the results table. +* display_chart: bool, If the widget should show the chart. +* display_chart_selector: bool, If the widget should show the chart selector links or just display the default,or the set chart_id, chart. +* success_callback: string, the name of a javascript function that will be called after the report data is retrieved. +* failure_callback: string, the name of a javascript function that will be called if the report data retrieval fails. +* template_name: string, the template name used to render the widget. Default to `slick_reporting/widget_template.html` + + +This code above will be actually rendered as this in the html page: + +.. code-block:: html+django + +
+
+
+ + +
+ + +
+ +
+
+
+
+ +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 the widget from loading automatically. + +The ``data-report-url`` attribute is the url that will be used to fetch the data. +The ``data-extra-params`` attribute is used to pass extra parameters to the report. +The ``data-success-callback`` attribute is used to pass a javascript function that will be called after +the report data is retrieved. +The ``data-fail-callback`` attribute is used to pass a javascript function +that will be called if the report data retrieval fails. +The ``report-form-selector`` attribute is used to pass a jquery selector +that will be used to find the form that will be used to pass extra parameters +to the report. +The ``data-chart-id`` attribute is used to pass the id of the chart that will +be rendered. The ``data-display-chart-selector`` attribute is used to pass +if the report loader should display the chart selectors links. + + +The ``data-report-chart`` attribute is used by the javascript to find the +container for the chart. The ``data-report-table`` attribute is used by the +javascript to find the container for the table. + + +``get_widget`` Tag can accept a ``template_name`` parameter to render the +report using a custom template. By default it renders the +``erp_reporting/report_widget.html`` template. + +Default Arguments +----------------- + +extra_params +success_callback +failure_callback +display_chart +display_table +chart_id +display_title +title (default to report report title) + + + + + +Customization +------------- + +You You can customize how the widget is loading by defining your own success call-back +and fail call-back functions. + +The success call-back function will receive the report data as a parameter + + +.. code-block:: html+django + + {% load i18n static erp_reporting_tags %} + +
+ {% get_report base_model='expense' report_slug='ExpensesTotalStatement' as ExpensesTotalStatement %} + {% get_html_panel ExpensesTotalStatement data-success-callback='my_success_callback' %} +
+ + diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index e8c489c..1c69103 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -65,7 +65,7 @@ In Slick Reporting, you can do the same thing by creating a report view looking from django.db.models import Sum from slick_reporting.views import ReportView, Chart - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from .models import Sales @@ -75,8 +75,8 @@ In Slick Reporting, you can do the same thing by creating a report view looking group_by = "product" columns = [ "name", - SlickReportField.create(Sum, "quantity", verbose_name="Total quantity sold", is_summable=False), - SlickReportField.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 = [ @@ -122,7 +122,7 @@ You can also export the report to CSV. from django.db.models import Sum from slick_reporting.views import ReportView, Chart - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField from .models import SalesTransaction @@ -132,7 +132,7 @@ You can also export the report to CSV. group_by = "client__country" # notice the double underscore columns = [ "client__country", - SlickReportField.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 = [ @@ -154,10 +154,10 @@ A time series report is a report that computes the data for each period of time. .. code-block:: python from django.utils.translation import gettext_lazy as _ - from slick_reporting.fields import SlickReportField + from slick_reporting.fields import ComputationField - class SumValueComputationField(SlickReportField): + class SumValueComputationField(ComputationField): computation_method = Sum computation_field = "value" verbose_name = _("Sales Value") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f05adf1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.black] +line-length = 120 + + +[tool.ruff] +line-length = 120 \ No newline at end of file diff --git a/slick_reporting/__init__.py b/slick_reporting/__init__.py index 12a81d1..d862c93 100644 --- a/slick_reporting/__init__.py +++ b/slick_reporting/__init__.py @@ -1,5 +1,5 @@ default_app_config = "slick_reporting.apps.ReportAppConfig" -VERSION = (1, 0, 2) +VERSION = (1, 1, 0) -__version__ = "1.0.2" +__version__ = "1.1.0" diff --git a/slick_reporting/decorators.py b/slick_reporting/decorators.py index 8139500..4226141 100644 --- a/slick_reporting/decorators.py +++ b/slick_reporting/decorators.py @@ -10,12 +10,12 @@ class AuthorAdmin(admin.ModelAdmin): A kwarg of `site` can be passed as the admin site, otherwise the default admin site will be used. """ - from .fields import SlickReportField + from .fields import ComputationField from .registry import field_registry def _model_admin_wrapper(admin_class): - if not issubclass(admin_class, SlickReportField): - raise ValueError("Wrapped class must subclass SlickReportField.") + if not issubclass(admin_class, ComputationField): + raise ValueError("Wrapped class must subclass ComputationField.") field_registry.register(report_field) diff --git a/slick_reporting/fields.py b/slick_reporting/fields.py index 54a6478..f0f461c 100644 --- a/slick_reporting/fields.py +++ b/slick_reporting/fields.py @@ -1,4 +1,6 @@ -from django.db.models import Sum +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 _ @@ -6,7 +8,7 @@ from .registry import field_registry -class SlickReportField(object): +class ComputationField(object): """ Computation field responsible for making the calculation unit """ @@ -65,7 +67,7 @@ def __new__(cls, *args, **kwargs): """ if not cls.name: raise ValueError(f"ReportField {cls} must have a name") - return super(SlickReportField, cls).__new__(cls) + return super(ComputationField, cls).__new__(cls) @classmethod def create(cls, method, field, name=None, verbose_name=None, is_summable=True): @@ -108,26 +110,16 @@ def __init__( group_by=None, group_by_custom_querysets=None, ): - super(SlickReportField, self).__init__() + super(ComputationField, 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.queryset = self.report_model._default_manager.all() if self.queryset is None else self.queryset - self.group_by_custom_querysets = ( - self.group_by_custom_querysets or group_by_custom_querysets - ) + self.group_by_custom_querysets = self.group_by_custom_querysets or group_by_custom_querysets - self.calculation_field = ( - calculation_field if calculation_field else self.calculation_field - ) - self.calculation_method = ( - calculation_method if calculation_method else self.calculation_method - ) + self.calculation_field = calculation_field if calculation_field else self.calculation_field + self.calculation_method = calculation_method if calculation_method else self.calculation_method self.plus_side_q = self.plus_side_q or plus_side_q self.minus_side_q = self.minus_side_q or minus_side_q self.requires = self.requires or [] @@ -141,10 +133,7 @@ def __init__( @classmethod def _get_required_classes(cls): requires = cls.requires or [] - return [ - field_registry.get_field_by_name(x) if type(x) is str else x - for x in requires - ] + 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) @@ -155,10 +144,7 @@ def apply_q_minus_filter(self, qs): def apply_aggregation(self, queryset, group_by=""): annotation = self.calculation_method(self.calculation_field) if self.group_by_custom_querysets: - output = [] - for group_by_query in self.group_by_custom_querysets: - output.append(group_by_query.aggregate(annotation)) - return output + return queryset.aggregate(annotation) elif group_by: queryset = queryset.values(group_by).annotate(annotation) else: @@ -176,13 +162,23 @@ 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()) - - debit_results, credit_results = self.prepare( - q_filters, kwargs_filters, **kwargs - ) + 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 - def prepare(self, q_filters=None, kwargs_filters=None, **kwargs): + def prepare_custom_group_by_queryset(self, q_filters=None, kwargs_filters=None, **kwargs): + debit_output, credit_output = [], [] + for index, queryset in enumerate(self.group_by_custom_querysets): + debit, credit = self.prepare(q_filters, kwargs_filters, queryset, **kwargs) + if debit: + debit_output.append(debit) + if credit: + credit_output.append(credit) + return debit_output, credit_output + + def prepare(self, q_filters=None, kwargs_filters=None, queryset=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 @@ -194,9 +190,11 @@ def prepare(self, q_filters=None, kwargs_filters=None, **kwargs): :param kwargs: :return: """ - queryset = self.get_queryset() + queryset = queryset or self.get_queryset() group_by = "" if self.prevent_group_by else self.group_by if q_filters: + if type(q_filters) is Q: + q_filters = [q_filters] queryset = queryset.filter(*q_filters) if kwargs_filters: queryset = queryset.filter(**kwargs_filters) @@ -232,9 +230,7 @@ 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 - ) + return get_calculation_annotation(self.calculation_field, self.calculation_method) def _prepare_dependencies( self, @@ -249,6 +245,8 @@ def _prepare_dependencies( self.report_model, date_field=self.date_field, group_by=self.group_by, + queryset=self.queryset, + group_by_custom_querysets=self.group_by_custom_querysets, ) results = dep.init_preparation(q_filters, extra_filters) values[dep.name] = {"results": results, "instance": dep} @@ -295,11 +293,7 @@ def _resolve_dependencies(self, current_obj, name=None): return dep_results def extract_data(self, cached, current_obj): - group_by = ( - "" - if self.prevent_group_by - else (self.group_by or self.group_by_custom_querysets) - ) + 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() @@ -401,7 +395,7 @@ def get_time_series_field_verbose_name(cls, date_period, index, dates, pattern): return f"{cls.verbose_name} {date_period[0].strftime(dt_format)} - {date_period[1].strftime(dt_format)}" -class FirstBalanceField(SlickReportField): +class FirstBalanceField(ComputationField): name = "__fb__" verbose_name = _("opening balance") @@ -417,7 +411,7 @@ def prepare(self, q_filters=None, extra_filters=None, **kwargs): field_registry.register(FirstBalanceField) -class TotalReportField(SlickReportField): +class TotalReportField(ComputationField): name = "__total__" verbose_name = _("Sum of value") requires = ["__debit__", "__credit__"] @@ -426,7 +420,7 @@ class TotalReportField(SlickReportField): field_registry.register(TotalReportField) -class BalanceReportField(SlickReportField): +class BalanceReportField(ComputationField): name = "__balance__" verbose_name = _("Closing Total") requires = ["__fb__"] @@ -442,7 +436,7 @@ def final_calculation(self, debit, credit, dep_dict): field_registry.register(BalanceReportField) -class PercentageToBalance(SlickReportField): +class PercentageToBalance(ComputationField): requires = [BalanceReportField] name = "PercentageToBalance" verbose_name = _("%") @@ -455,7 +449,7 @@ def final_calculation(self, debit, credit, dep_dict): return (obj_balance / total) * 100 -class CreditReportField(SlickReportField): +class CreditReportField(ComputationField): name = "__credit__" verbose_name = _("Credit") @@ -467,7 +461,7 @@ def final_calculation(self, debit, credit, dep_dict): @field_registry.register -class DebitReportField(SlickReportField): +class DebitReportField(ComputationField): name = "__debit__" verbose_name = _("Debit") @@ -476,7 +470,7 @@ def final_calculation(self, debit, credit, dep_dict): @field_registry.register -class CreditQuantityReportField(SlickReportField): +class CreditQuantityReportField(ComputationField): name = "__credit_quantity__" verbose_name = _("Credit QTY") calculation_field = "quantity" @@ -487,7 +481,7 @@ def final_calculation(self, debit, credit, dep_dict): @field_registry.register -class DebitQuantityReportField(SlickReportField): +class DebitQuantityReportField(ComputationField): name = "__debit_quantity__" calculation_field = "quantity" verbose_name = _("Debit QTY") @@ -497,7 +491,7 @@ def final_calculation(self, debit, credit, dep_dict): return debit -class TotalQTYReportField(SlickReportField): +class TotalQTYReportField(ComputationField): name = "__total_quantity__" verbose_name = _("Total QTY") calculation_field = "quantity" @@ -517,7 +511,7 @@ class FirstBalanceQTYReportField(FirstBalanceField): field_registry.register(FirstBalanceQTYReportField) -class BalanceQTYReportField(SlickReportField): +class BalanceQTYReportField(ComputationField): name = "__balance_quantity__" verbose_name = _("Closing QTY") calculation_field = "quantity" @@ -532,3 +526,22 @@ def final_calculation(self, debit, credit, dep_dict): field_registry.register(BalanceQTYReportField) + + +class SlickReportField(ComputationField): + @staticmethod + def warn(): + warn( + "SlickReportField name is deprecated, please use ComputationField instead.", + DeprecationWarning, + stacklevel=2, + ) + + @classmethod + def create(cls, method, field, name=None, verbose_name=None, is_summable=True): + cls.warn() + return super().create(method, field, name, verbose_name, is_summable) + + def __new__(cls, *args, **kwargs): + cls.warn() + return super().__new__(cls, *args, **kwargs) diff --git a/slick_reporting/forms.py b/slick_reporting/forms.py index cfb169a..0cc717b 100644 --- a/slick_reporting/forms.py +++ b/slick_reporting/forms.py @@ -23,11 +23,11 @@ def default_formfield_callback(f, **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, + 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 @@ -111,27 +111,19 @@ def get_filters(self): ) def get_start_date(self): - raise NotImplementedError( - "get_start_date() must be implemented in subclass," - "should return a datetime object" - ) + 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" - ) + 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" + "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" + "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): @@ -213,9 +205,7 @@ def get_crosstab_ids(self): if self.crosstab_field_klass: if self.crosstab_field_klass.is_relation: 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 [x for x in qs.values_list(self.crosstab_field_related_name, flat=True)] else: return self.cleaned_data.get(self.crosstab_key_name) return [] @@ -228,9 +218,7 @@ def get_crispy_helper(self, foreign_keys_map=None, crosstab_model=None, **kwargs 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 - ), + crosstab_display_compute_remainder=getattr(self, "crosstab_display_compute_remainder", False), **kwargs, ) @@ -243,19 +231,19 @@ def _default_foreign_key_widget(f_field): 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, + 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 @@ -266,8 +254,10 @@ def report_form_factory( :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 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 @@ -294,9 +284,7 @@ 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", app_settings.SLICK_REPORTING_DEFAULT_START_DATE), widget=forms.DateTimeInput(attrs={"autocomplete": "off"}), ) @@ -340,7 +328,6 @@ def report_form_factory( crosstab_field_klass = get_field_from_query_text(crosstab_model, model) if crosstab_field_klass.is_relation: - crosstab_field_related_name = crosstab_field_klass.to_fields[0] else: crosstab_field_related_name = crosstab_field_klass.name @@ -349,10 +336,17 @@ def report_form_factory( if crosstab_field_klass.is_relation: pass else: - fields[crosstab_field_related_name] = forms.MultipleChoiceField(choices=get_choices_form_queryset_list( - list(crosstab_field_klass.model.objects.values_list(crosstab_field_related_name, flat=True).distinct())), - required=False, label=crosstab_field_klass.verbose_name) - + fields[crosstab_field_related_name] = forms.MultipleChoiceField( + choices=get_choices_form_queryset_list( + list( + crosstab_field_klass.model.objects.values_list( + crosstab_field_related_name, flat=True + ).distinct() + ) + ), + required=False, + label=crosstab_field_klass.verbose_name, + ) bases = ( SlickReportForm, diff --git a/slick_reporting/generator.py b/slick_reporting/generator.py index e6a432b..40592c7 100644 --- a/slick_reporting/generator.py +++ b/slick_reporting/generator.py @@ -7,7 +7,7 @@ from django.db.models import Q, ForeignKey from .app_settings import SLICK_REPORTING_DEFAULT_CHARTS_ENGINE -from .fields import SlickReportField +from .fields import ComputationField from .helpers import get_field_from_query_text from .registry import field_registry @@ -217,13 +217,9 @@ def __init__( self.queryset = main_queryset if not self.report_model and self.queryset is None: - raise ImproperlyConfigured( - "report_model or queryset must be set on a class level or via init" - ) + raise ImproperlyConfigured("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 - ) + 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(), @@ -236,12 +232,8 @@ 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.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 {} @@ -249,44 +241,36 @@ def __init__( self.crosstab_columns = crosstab_columns or self.crosstab_columns or [] self.crosstab_ids = self.crosstab_ids or crosstab_ids or [] - self.crosstab_ids_custom_filters = ( - self.crosstab_ids_custom_filters or crosstab_ids_custom_filters or [] - ) + self.crosstab_ids_custom_filters = self.crosstab_ids_custom_filters or crosstab_ids_custom_filters or [] self.crosstab_compute_remainder = ( - self.crosstab_compute_remainder - if crosstab_compute_remainder is None - else crosstab_compute_remainder + self.crosstab_compute_remainder if crosstab_compute_remainder is None else crosstab_compute_remainder ) self.format_row = format_row_func or self._default_format_row - main_queryset = ( - self.report_model.objects if main_queryset is None else main_queryset - ) + 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 - self.group_by_custom_querysets = ( - group_by_custom_querysets or self.group_by_custom_querysets or [] - ) + self.group_by_custom_querysets = group_by_custom_querysets or self.group_by_custom_querysets or [] - self.group_by_custom_querysets_column_verbose_name = group_by_custom_querysets_column_verbose_name or self.group_by_custom_querysets_column_verbose_name or "" + self.group_by_custom_querysets_column_verbose_name = ( + group_by_custom_querysets_column_verbose_name or self.group_by_custom_querysets_column_verbose_name or "" + ) self.time_series_pattern = self.time_series_pattern or time_series_pattern self.time_series_columns = self.time_series_columns or time_series_columns - self.time_series_custom_dates = ( - self.time_series_custom_dates or time_series_custom_dates - ) + self.time_series_custom_dates = self.time_series_custom_dates or time_series_custom_dates self.container_class = container_class - 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): + 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( - f"date_field or [start_date_field_name and end_date_field_name] must be set for {self}" + f"date_field or [start_date_field_name and end_date_field_name] must be set for {container_class or self}" ) self._prepared_results = {} @@ -305,9 +289,7 @@ def __init__( if self.group_by: try: - self.group_by_field = get_field_from_query_text( - self.group_by, self.report_model - ) + self.group_by_field = get_field_from_query_text(self.group_by, self.report_model) except (IndexError, AttributeError): raise ImproperlyConfigured( @@ -323,12 +305,8 @@ def __init__( # doc_types = form.get_doc_type_plus_minus_lists() doc_types = [], [] - self.doc_type_plus_list = ( - list(doc_type_plus_list) if doc_type_plus_list else doc_types[0] - ) - self.doc_type_minus_list = ( - list(doc_type_minus_list) if doc_type_minus_list else doc_types[1] - ) + self.doc_type_plus_list = list(doc_type_plus_list) if doc_type_plus_list else doc_types[0] + self.doc_type_minus_list = list(doc_type_minus_list) if doc_type_minus_list else doc_types[1] self.swap_sign = self.swap_sign or swap_sign self.limit_records = self.limit_records or limit_records @@ -338,41 +316,29 @@ def __init__( # Preparing actions self._parse() + + self.main_queryset = self.prepare_queryset(main_queryset) + self._prepare_report_dependencies() + + def prepare_queryset(self, queryset): if self.group_by_custom_querysets: - self.main_queryset = [ - {"__index__": i} for i, v in enumerate(self.group_by_custom_querysets) - ] + return [{"__index__": i} for i, v in enumerate(self.group_by_custom_querysets)] elif self.group_by: - self.main_queryset = self._apply_queryset_options(main_queryset) + main_queryset = self._apply_queryset_options(queryset) if type(self.group_by_field) is ForeignKey: - ids = self.main_queryset.values_list( - self.group_by_field_attname - ).distinct() + ids = 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 - ] + 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( + final_fields = concrete_fields + list(set(self.get_database_columns()) - set(concrete_fields)) + return 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.main_queryset.distinct().values( - self.group_by_field_attname - ) - else: - if self.time_series_pattern: - self.main_queryset = [{}] - else: - self.main_queryset = self._apply_queryset_options( - main_queryset, self.get_database_columns() - ) - self._prepare_report_dependencies() + return main_queryset.distinct().values(self.group_by_field_attname) + + return [{}] def _remove_order(self, main_queryset): """ @@ -419,9 +385,7 @@ def _construct_crosstab_filter(self, col_data, queryset_filters=None): 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 - ) + field = get_field_from_query_text(col_data["crosstab_field"], self.report_model) column_name = field.column if col_data["is_remainder"] and not queryset_filters: filters = [~Q(**{f"{column_name}__in": self.crosstab_ids})] @@ -430,7 +394,7 @@ def _construct_crosstab_filter(self, col_data, queryset_filters=None): return filters, {} def _prepare_report_dependencies(self): - from .fields import SlickReportField + from .fields import ComputationField all_columns = ( ("normal", self._parsed_columns), @@ -441,7 +405,7 @@ def _prepare_report_dependencies(self): for col_data in window_cols: klass = col_data["ref"] - if isclass(klass) and issubclass(klass, SlickReportField): + if isclass(klass) and issubclass(klass, ComputationField): dependencies_names = klass.get_full_dependency_list() # check if any of these dependencies is on the report, if found we call the child to @@ -453,8 +417,7 @@ def _prepare_report_dependencies(self): and ( ( window == "time_series" - and x.get("start_date", "") - == col_data.get("start_date", "") + and x.get("start_date", "") == col_data.get("start_date", "") and x.get("end_date") == col_data.get("end_date") ) or window == "crosstab" @@ -462,15 +425,13 @@ def _prepare_report_dependencies(self): ) ] for field in fields_on_report: - self._report_fields_dependencies[window][ - field["name"] - ] = col_data["name"] + self._report_fields_dependencies[window][field["name"]] = col_data["name"] for col_data in window_cols: klass = col_data["ref"] name = col_data["name"] # if column has a dependency then skip it - if not (isclass(klass) and issubclass(klass, SlickReportField)): + if not (isclass(klass) and issubclass(klass, ComputationField)): continue if self._report_fields_dependencies[window].get(name, False): continue @@ -487,18 +448,11 @@ def _prepare_report_dependencies(self): q_filters = None date_filter = { - f"{self.start_date_field_name}__gte": col_data.get( - "start_date", self.start_date - ), - f"{self.end_date_field_name}__lt": col_data.get( - "end_date", self.end_date - ), + f"{self.start_date_field_name}__gte": col_data.get("start_date", self.start_date), + f"{self.end_date_field_name}__lt": col_data.get("end_date", self.end_date), } date_filter.update(self.kwargs_filters) - if ( - window == "crosstab" - or col_data.get("computation_flag", "") == "crosstab" - ): + if window == "crosstab" or col_data.get("computation_flag", "") == "crosstab": q_filters, kw_filters = col_data["queryset_filters"] date_filter.update(kw_filters) @@ -529,9 +483,7 @@ def _get_record_data(self, obj, columns): elif self.group_by: if self.group_by_field.related_model and "__" not in self.group_by: - primary_key_name = self.get_primary_key_name( - self.group_by_field.related_model - ) + primary_key_name = self.get_primary_key_name(self.group_by_field.related_model) else: primary_key_name = self.group_by_field_attname @@ -543,21 +495,18 @@ def _get_record_data(self, obj, columns): name = col_data["name"] if col_data.get("source", "") == "attribute_field": - data[name] = col_data["ref"](self, obj, data) + data[name] = col_data["ref"](obj, data) elif col_data.get("source", "") == "container_class_attribute_field": data[name] = col_data["ref"](obj, data) elif ( - col_data.get("source", "") == "magic_field" - and (self.group_by or self.group_by_custom_querysets) - ) or (self.time_series_pattern and not self.group_by): + col_data.get("source", "") == "magic_field" and (self.group_by or self.group_by_custom_querysets) + ) or (not (self.group_by or self.group_by_custom_querysets)): source = self._report_fields_dependencies[window].get(name, False) if source: computation_class = self.report_fields_classes[source] - value = computation_class.get_dependency_value( - group_by_val, col_data["ref"].name - ) + value = computation_class.get_dependency_value(group_by_val, col_data["ref"].name) else: try: computation_class = self.report_fields_classes[name] @@ -573,11 +522,7 @@ def _get_record_data(self, obj, columns): return data def get_report_data(self): - main_queryset = ( - self.main_queryset[: self.limit_records] - if self.limit_records - else self.main_queryset - ) + main_queryset = self.main_queryset[: self.limit_records] if self.limit_records else self.main_queryset all_columns = ( ("normal", self._parsed_columns), @@ -624,11 +569,7 @@ def check_columns( if group_by: try: - group_by_field = [ - x - for x in report_model._meta.get_fields() - if x.name == group_by.split("__")[0] - ][0] + group_by_field = [x for x in report_model._meta.get_fields() if x.name == group_by.split("__")[0]][0] except IndexError: raise ImproperlyConfigured( f"ReportView {cls}: Could not find the group_by field: `{group_by}` in report_model: `{report_model}`" @@ -652,19 +593,17 @@ def check_columns( attribute_field = None is_container_class_attribute = False - if type(col) is str: + if isinstance(col, str): attribute_field = getattr(cls, col, None) if attribute_field is None: is_container_class_attribute = True attribute_field = getattr(container_class, col, None) - elif issubclass(col, SlickReportField): + elif issubclass(col, ComputationField): magic_field_class = col try: - magic_field_class = ( - magic_field_class or field_registry.get_field_by_name(col) - ) + magic_field_class = magic_field_class or field_registry.get_field_by_name(col) except KeyError: magic_field_class = None @@ -672,9 +611,7 @@ def check_columns( col_data = { "name": col, "verbose_name": getattr(attribute_field, "verbose_name", col), - "source": "container_class_attribute_field" - if is_container_class_attribute - else "attribute_field", + "source": "container_class_attribute_field" if is_container_class_attribute else "attribute_field", "ref": attribute_field, "type": "text", } @@ -703,16 +640,10 @@ def check_columns( parsed_columns.append(col_data) continue - model_to_use = ( - group_by_model - if group_by and "__" not in group_by - else report_model - ) + model_to_use = group_by_model if group_by and "__" not in group_by else report_model group_by_str = str(group_by) if "__" in group_by_str: - related_model = get_field_from_query_text( - group_by, model_to_use - ).related_model + related_model = get_field_from_query_text(group_by, model_to_use).related_model model_to_use = related_model if related_model else model_to_use try: @@ -756,11 +687,7 @@ def _parse(self): self._time_series_parsed_columns = self.get_time_series_parsed_columns() def get_database_columns(self): - return [ - col["name"] - for col in self.parsed_columns - if "source" in col and col["source"] == "database" - ] + return [col["name"] for col in self.parsed_columns if "source" in col and col["source"] == "database"] # def get_method_columns(self): # return [col['name'] for col in self.parsed_columns if col['type'] == 'method'] @@ -801,20 +728,16 @@ def get_time_series_parsed_columns(self): for col in cols: magic_field_class = None - if type(col) is str: + if isinstance(col, str): magic_field_class = field_registry.get_field_by_name(col) - elif issubclass(col, SlickReportField): + elif issubclass(col, ComputationField): magic_field_class = col _values.append( { - "name": magic_field_class.name - + "TS" - + dt[1].strftime("%Y%m%d"), + "name": magic_field_class.name + "TS" + dt[1].strftime("%Y%m%d"), "original_name": magic_field_class.name, - "verbose_name": self.get_time_series_field_verbose_name( - magic_field_class, dt, index, series - ), + "verbose_name": self.get_time_series_field_verbose_name(magic_field_class, dt, index, series), "ref": magic_field_class, "start_date": dt[0], "end_date": dt[1], @@ -827,18 +750,14 @@ def get_time_series_parsed_columns(self): if self._crosstab_parsed_columns: for parsed_col in self._crosstab_parsed_columns: parsed_col = parsed_col.copy() - parsed_col["name"] = ( - parsed_col["name"] + "TS" + dt[1].strftime("%Y%m%d") - ) + parsed_col["name"] = parsed_col["name"] + "TS" + dt[1].strftime("%Y%m%d") parsed_col["start_date"] = dt[0] parsed_col["end_date"] = dt[1] _values.append(parsed_col) return _values - def get_time_series_field_verbose_name( - self, computation_class, date_period, index, series, pattern=None - ): + def get_time_series_field_verbose_name(self, computation_class, date_period, index, series, pattern=None): """ Sent the column data to construct a verbose name. Default implementation is delegated to the ReportField.get_time_series_field_verbose_name @@ -849,9 +768,7 @@ def get_time_series_field_verbose_name( :return: a verbose string """ pattern = pattern or self.time_series_pattern - return computation_class.get_time_series_field_verbose_name( - date_period, index, series, pattern - ) + return computation_class.get_time_series_field_verbose_name(date_period, index, series, pattern) def get_custom_time_series_dates(self): """ @@ -886,9 +803,7 @@ def _get_time_series_dates(self, series=None, start_date=None, end_date=None): elif series == "custom": return self.get_custom_time_series_dates() else: - raise NotImplementedError( - f'"{series}" is not implemented for time_series_pattern' - ) + raise NotImplementedError(f'"{series}" is not implemented for time_series_pattern') done = False @@ -922,9 +837,9 @@ def get_crosstab_parsed_columns(self): for col in report_columns: magic_field_class = None - if type(col) is str: + if isinstance(col, str): magic_field_class = field_registry.get_field_by_name(col) - elif issubclass(col, SlickReportField): + elif issubclass(col, ComputationField): magic_field_class = col crosstab_column = { @@ -936,16 +851,12 @@ def get_crosstab_parsed_columns(self): "ref": magic_field_class, "id": crosstab_id, "crosstab_field": self.crosstab_field, - "is_remainder": counter == ids_length - if self.crosstab_compute_remainder - else False, + "is_remainder": counter == ids_length if self.crosstab_compute_remainder else False, "source": "magic_field" if magic_field_class else "", "is_summable": magic_field_class.is_summable, "computation_flag": "crosstab", # a flag, todo find a better way probably } - crosstab_column["queryset_filters"] = self._construct_crosstab_filter( - crosstab_column, queryset_filters - ) + crosstab_column["queryset_filters"] = self._construct_crosstab_filter(crosstab_column, queryset_filters) output_cols.append(crosstab_column) @@ -971,14 +882,10 @@ def get_metadata(self): metadata = { "time_series_pattern": self.time_series_pattern, "time_series_column_names": [x["name"] for x in time_series_columns], - "time_series_column_verbose_names": [ - x["verbose_name"] for x in time_series_columns - ], + "time_series_column_verbose_names": [x["verbose_name"] for x in time_series_columns], "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 - ], + "crosstab_column_verbose_names": [x["verbose_name"] for x in crosstab_columns], } return metadata @@ -1004,18 +911,14 @@ def get_columns_data(self): ) return data - def get_full_response( - self, data=None, report_slug=None, chart_settings=None, default_chart_title=None - ): + def get_full_response(self, data=None, report_slug=None, chart_settings=None, default_chart_title=None): data = data or self.get_report_data() data = { "report_slug": report_slug or self.__class__.__name__, "data": data, "columns": self.get_columns_data(), "metadata": self.get_metadata(), - "chart_settings": self.get_chart_settings( - chart_settings, default_chart_title=default_chart_title - ), + "chart_settings": self.get_chart_settings(chart_settings, default_chart_title=default_chart_title), } return data @@ -1032,22 +935,20 @@ def get_chart_settings(self, chart_settings=None, default_chart_title=None): chart["id"] = chart.get("id", f"{i}") chart_type = chart.get("type", "line") - if ( - chart_type == "column" - and SLICK_REPORTING_DEFAULT_CHARTS_ENGINE == "chartsjs" - ): + if chart_type == "column" and SLICK_REPORTING_DEFAULT_CHARTS_ENGINE == "chartsjs": chart["type"] = "bar" if not chart.get("title", False): chart["title"] = report_title - chart["engine_name"] = chart.get( - "engine_name", SLICK_REPORTING_DEFAULT_CHARTS_ENGINE - ) + chart["engine_name"] = chart.get("engine_name", SLICK_REPORTING_DEFAULT_CHARTS_ENGINE) output.append(chart) return output class ListViewReportGenerator(ReportGenerator): + def prepare_queryset(self, queryset): + return self._apply_queryset_options(queryset, self.get_database_columns()) + def _apply_queryset_options(self, query, fields=None): """ Apply the filters to the main queryset which will computed results be mapped to @@ -1065,8 +966,8 @@ def _apply_queryset_options(self, query, fields=None): if filters: query = query.filter(**filters) - # if fields: - # return query.values(*fields) + if fields: + return query.values(*fields) return query def _get_record_data(self, obj, columns): @@ -1081,9 +982,7 @@ def _get_record_data(self, obj, columns): group_by_val = None if self.group_by: if self.group_by_field.related_model and "__" not in self.group_by: - primary_key_name = self.get_primary_key_name( - self.group_by_field.related_model - ) + primary_key_name = self.get_primary_key_name(self.group_by_field.related_model) else: primary_key_name = self.group_by_field_attname @@ -1100,15 +999,13 @@ def _get_record_data(self, obj, columns): elif col_data.get("source", "") == "container_class_attribute_field": data[name] = col_data["ref"](obj) - elif ( - col_data.get("source", "") == "magic_field" and self.group_by - ) or (self.time_series_pattern and not self.group_by): + elif (col_data.get("source", "") == "magic_field" and self.group_by) or ( + self.time_series_pattern and not self.group_by + ): source = self._report_fields_dependencies[window].get(name, False) if source: computation_class = self.report_fields_classes[source] - value = computation_class.get_dependency_value( - group_by_val, col_data["ref"].name - ) + value = computation_class.get_dependency_value(group_by_val, col_data["ref"].name) else: try: computation_class = self.report_fields_classes[name] @@ -1120,10 +1017,7 @@ def _get_record_data(self, obj, columns): data[name] = value else: - if col_data.get("type", "") == "choice": - data[name] = getattr(obj, f"get_{name}_display", "")() - else: - data[name] = getattr(obj, name, "") + data[name] = obj[name] return data def _remove_order(self, main_queryset): diff --git a/slick_reporting/registry.py b/slick_reporting/registry.py index 1149188..133f5f2 100644 --- a/slick_reporting/registry.py +++ b/slick_reporting/registry.py @@ -16,9 +16,7 @@ def register(self, report_field, override=False): :return: report_field passed """ if report_field.name in self._registry and not override: - raise AlreadyRegistered( - f"The field name {report_field.name} is used before and `override` is False" - ) + raise AlreadyRegistered(f"The field name {report_field.name} is used before and `override` is False") self._registry[report_field.name] = report_field return report_field @@ -29,7 +27,7 @@ def unregister(self, report_field): :param report_field: a Report field class or a ReportField Name :return: None """ - name = report_field if type(report_field) is str else report_field.name + name = report_field if isinstance(report_field, str) else report_field.name if name not in self._registry: raise NotRegistered(report_field) del self._registry[name] diff --git a/slick_reporting/static/slick_reporting/erp_framework.js b/slick_reporting/static/slick_reporting/erp_framework.js deleted file mode 100644 index 5e91eec..0000000 --- a/slick_reporting/static/slick_reporting/erp_framework.js +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Created by ramez on 1/5/15. - */ - - - -function parseArabicNumbers(str) { - str = typeof str == 'undefined' ? '0' : str; - return Number(str.replace(/[٠١٢٣٤٥٦٧٨٩]/g, function (d) { - return d.charCodeAt(0) - 1632; - }).replace(/[۰۱۲۳۴۵۶۷۸۹]/g, function (d) { - return d.charCodeAt(0) - 1776; - })); -} - - -function calculateTotalOnObjectArray(data, columns) { - // Compute totals in array of objects - // example : - // calculateTotalOnObjectArray ([{ value1:500, value2: 70} , {value1:200, value2:15} ], ['value']) - // return {'value1': 700, value2:85} - - let total_container = {}; - for (let r = 0; r < data.length; r++) { - - for (let i = 0; i < columns.length; i++) { - if (typeof total_container[columns[i]] == 'undefined') { - total_container[columns[i]] = 0; - } - let val = data[r][columns[i]]; - if (val === '-') val = 0; - - else if (typeof (val) == 'string') { - try { - val = val.replace(/,/g, ''); - } catch (err) { - console.log(err, val, typeof (val)); - } - } - total_container[columns[i]] += parseFloat(val); - } - } - return total_container; -} - -function executeFunctionByName(functionName, context /*, args */) { - let args = Array.prototype.slice.call(arguments, 2); - let namespaces = functionName.split("."); - let func = namespaces.pop(); - for (let i = 0; i < namespaces.length; i++) { - context = context[namespaces[i]]; - } - try { - func = context[func]; - if (typeof func == 'undefined') { - throw 'Function {0} is not found the context {1}'.format(functionName, context); - } - - } catch (err) { - console.error('Function {0} is not found the context {1}'.format(functionName, context), err) - } - return func.apply(context, args); -} - - -(function ($) { - - - // let opts = $.extend({}, $.erp_framework.defaults, options); - - function enable_tab_support() { - //support for enter key as a navigation - let focusables = $(':focusable'); - $('input').not('[ra_autocomplete_bind="true"]').not('[type="search"]').not('#top_search_box') - .on("keydown", function (event) { - if (event.keyCode === 13) { - let current = focusables.index(this); - let check = false; - while (!check) { - let next = getNext(current); - let readOnly = $(next).attr('readonly'); - - if (typeof readOnly == 'undefined') { - check = true; - } - if ($(next).hasClass('delete-row')) { - check = false; - } - current++; - } - next.focus(); - next.select(); - event.preventDefault(); - } - - function getNext(current) { - return focusables.eq(current + 1).length ? focusables.eq(current + 1) : focusables.eq(0); - } - }); - } - - function smartParseFloat(number, to_fixed) { - // Wrapper around parseFloat aimed to deliver only numbers - - let val = parseFloat(number); - if (isNaN(val)) return 0; - else { - if (to_fixed > 0 && to_fixed <= 20) return val.toFixed(to_fixed); - else return val - } - - } - - function focus_first($div) { - $div = $div || $('body'); - $div.find('input:visible').not(':disabled').not('.hasDatepicker').not('.timeinput').first().select().focus(); - } - - function adjustNumberWidget() { - $('input[type=number]').on('focus', function (e) { - $(this).on('mousewheel.disableScroll', function (e) { - e.preventDefault(); - var scrollTo = (e.originalEvent.wheelDelta * -1) + $(document.documentElement).scrollTop(); - $(document.documentElement).scrollTop(scrollTo); - }) - }).on('blur', function (e) { - $(this).off('mousewheel.disableScroll') - }); - } - - $.erp_framework = { - enterTabSupport: enable_tab_support, - smartParseFloat: smartParseFloat, - focus_first: focus_first, - - } ; - // }; - - $.erp_framework.defaults = { - debug: true, - - messages: { - - DoneMessage: "Done...", - SuccessMessage: " Done...", - ErrorMessage: "An error happened :( ", - WaitMessage: 'Just a moment...', - LoadingMessage: 'loading...', - total: 'Total', - - }, - urls: { - - }, - }; - - $.erp_framework.cache = {}; - $.erp_framework.rtl = false; - - $.erp_framework.debug = false; // turned on only on dev ; -}(jQuery)); - - diff --git a/slick_reporting/static/slick_reporting/erp_framework.chartsjs.js b/slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js similarity index 100% rename from slick_reporting/static/slick_reporting/erp_framework.chartsjs.js rename to slick_reporting/static/slick_reporting/slick_reporting.chartsjs.js diff --git a/slick_reporting/static/slick_reporting/erp_framework.datatable.js b/slick_reporting/static/slick_reporting/slick_reporting.datatable.js similarity index 91% rename from slick_reporting/static/slick_reporting/erp_framework.datatable.js rename to slick_reporting/static/slick_reporting/slick_reporting.datatable.js index d0af29d..cc95b47 100644 --- a/slick_reporting/static/slick_reporting/erp_framework.datatable.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.datatable.js @@ -20,7 +20,7 @@ let footer_th = ''; let footer_colspan = 0; let stop_colspan_detection = false; - let totals_container = calculateTotalOnObjectArray(data, total_fields); + let totals_container = $.slick_reporting.calculateTotalOnObjectArray(data, total_fields); if (data.length <= 1) { add_footer = false; } @@ -53,7 +53,7 @@ function buildAndInitializeDataTable(data, $elem, extraOptions, successFunction) { // Responsible for turning a ReportView Response into a datatable. - let opts = $.extend({}, $.erp_framework.datatable.defaults, extraOptions); + let opts = $.extend({}, $.slick_reporting.datatable.defaults, extraOptions); opts['datatableContainer'] = $elem; let datatable_container = opts.datatableContainer; @@ -72,7 +72,7 @@ if (total_fields.length === 0) provide_total = false; datatable_container.html(constructTable( - $.erp_framework.datatable.defaults.tableCssClass, data['columns'], column_names, + $.slick_reporting.datatable.defaults.tableCssClass, data['columns'], column_names, provide_total, opts.messages.total, total_fields, data.data)); initializeReportDatatable(datatable_container.find('table'), data, opts); @@ -104,7 +104,7 @@ tableSelector = typeof tableSelector != 'undefined' ? tableSelector : '.datatable'; extraOptions = typeof extraOptions != 'undefined' ? extraOptions : {}; - let opts = $.extend({}, $.erp_framework.datatable.defaults, extraOptions); + let opts = $.extend({}, $.slick_reporting.datatable.defaults, extraOptions); let dom = typeof (extraOptions.dom) == 'undefined' ? 'lfrtip' : extraOptions.dom; @@ -138,7 +138,7 @@ } - $.erp_framework.datatable = { + $.slick_reporting.datatable = { initializeDataTable: initializeReportDatatable, _cache: _cache, buildAdnInitializeDatatable: buildAndInitializeDataTable, @@ -147,12 +147,12 @@ } }(jQuery)); -$.erp_framework.datatable.defaults = { +$.slick_reporting.datatable.defaults = { enableFixedHeader: false, fixedHeaderZindex: 2001, messages: { - total: $.erp_framework.defaults.messages.total, + total: $.slick_reporting.defaults.total_label, }, tableCssClass: 'table table-xxs datatable-basic table-bordered table-striped table-hover ', diff --git a/slick_reporting/static/slick_reporting/erp_framework.highchart.js b/slick_reporting/static/slick_reporting/slick_reporting.highchart.js similarity index 98% rename from slick_reporting/static/slick_reporting/erp_framework.highchart.js rename to slick_reporting/static/slick_reporting/slick_reporting.highchart.js index f63c052..219d917 100644 --- a/slick_reporting/static/slick_reporting/erp_framework.highchart.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.highchart.js @@ -440,7 +440,12 @@ function displayChart(data, $elem, chart_id) { chart_id = chart_id || $elem.attr('data-report-default-chart') || ''; - let chart = $elem; + 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 try { diff --git a/slick_reporting/static/slick_reporting/main.js b/slick_reporting/static/slick_reporting/slick_reporting.js similarity index 67% rename from slick_reporting/static/slick_reporting/main.js rename to slick_reporting/static/slick_reporting/slick_reporting.js index 85e6e7b..5442699 100644 --- a/slick_reporting/static/slick_reporting/main.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.js @@ -1,5 +1,24 @@ (function ($) { + function executeFunctionByName(functionName, context /*, args */) { + let args = Array.prototype.slice.call(arguments, 2); + let namespaces = functionName.split("."); + let func = namespaces.pop(); + for (let i = 0; i < namespaces.length; i++) { + context = context[namespaces[i]]; + } + try { + func = context[func]; + if (typeof func == 'undefined') { + throw 'Function {0} is not found the context {1}'.format(functionName, context); + } + + } catch (err) { + console.error('Function {0} is not found the context {1}'.format(functionName, context), err) + } + return func.apply(context, args); +} + function getObjFromArray(objList, obj_key, key_value, failToFirst) { failToFirst = typeof (failToFirst) !== 'undefined'; if (key_value !== '') { @@ -49,7 +68,12 @@ $.slick_reporting = { 'getObjFromArray': getObjFromArray, 'calculateTotalOnObjectArray': calculateTotalOnObjectArray, + "executeFunctionByName": executeFunctionByName, + defaults:{ + total_label: 'Total', + } } + $.slick_reporting.cache = {} }(jQuery)); \ No newline at end of file diff --git a/slick_reporting/static/slick_reporting/erp_framework.report_loader.js b/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js similarity index 72% rename from slick_reporting/static/slick_reporting/erp_framework.report_loader.js rename to slick_reporting/static/slick_reporting/slick_reporting.report_loader.js index 748924e..a5bee58 100644 --- a/slick_reporting/static/slick_reporting/erp_framework.report_loader.js +++ b/slick_reporting/static/slick_reporting/slick_reporting.report_loader.js @@ -18,19 +18,19 @@ let chartElem = $elem.find('[data-report-chart]'); let chart_id = $elem.attr('data-chart-id'); let display_chart_selector = $elem.attr('data-display-chart-selector'); - chartElem.append(""); + // chartElem.append(""); if (chartElem.length !== 0 && data.chart_settings.length !== 0) { - $.erp_framework.report_loader.displayChart(data, chartElem, chart_id); + $.slick_reporting.report_loader.displayChart(data, chartElem, chart_id); } if (display_chart_selector !== "False" && data.chart_settings.length > 1) { - $.erp_framework.report_loader.createChartsUIfromResponse(data, $elem); + $.slick_reporting.report_loader.createChartsUIfromResponse(data, $elem); } let tableElem = $elem.find('[data-report-table]'); if (tableElem.length !== 0) { - $.erp_framework.datatable.buildAdnInitializeDatatable(data, tableElem); + $.slick_reporting.datatable.buildAdnInitializeDatatable(data, tableElem); } } @@ -50,15 +50,15 @@ catch (e){ console.error(e); } - executeFunctionByName($.erp_framework.report_loader.chart_engines[engine], window, data, $elem, chart_id); + $.slick_reporting.executeFunctionByName($.slick_reporting.report_loader.chart_engines[engine], window, data, $elem, chart_id); } function refreshReportWidget($elem, extra_params) { let successFunctionName = $elem.attr('data-success-callback'); - successFunctionName = successFunctionName || "$.erp_framework.report_loader.successCallback"; + successFunctionName = successFunctionName || "$.slick_reporting.report_loader.successCallback"; let failFunctionName = $elem.attr('data-fail-callback'); - failFunctionName = failFunctionName || "$.erp_framework.report_loader.failFunction"; + failFunctionName = failFunctionName || "$.slick_reporting.report_loader.failFunction"; let data = {}; @@ -66,24 +66,23 @@ extra_params = extra_params || '' let extraParams = extra_params + ($elem.attr('data-extra-params') || ''); - let formSelector = $elem.attr('report-form-selector'); + let formSelector = $elem.attr('data-form-selector'); if (formSelector) { data = $(formSelector).serialize(); } else { if (url === '#') return; // there is no actual url, probably not enough permissions - else url = url + '?'; if (extraParams !== '') { - url = url + extraParams; + url = url + "?" + extraParams; } } $.get(url, data, function (data) { - $.erp_framework.cache[data['report_slug']] = jQuery.extend(true, {}, data); - executeFunctionByName(successFunctionName, window, data, $elem); + $.slick_reporting.cache[data['report_slug']] = jQuery.extend(true, {}, data); + $.slick_reporting.executeFunctionByName(successFunctionName, window, data, $elem); }).fail(function (data) { - executeFunctionByName(failFunctionName, window, data, $elem); + $.slick_reporting.executeFunctionByName(failFunctionName, window, data, $elem); }); } @@ -125,17 +124,17 @@ return $container } - // $('body').on('click', 'a[data-chart-id]', function (e) { - // e.preventDefault(); - // let $this = $(this); - // let data = $.erp_framework.cache[$this.attr('data-report-slug')] - // let chart_id = $this.attr('data-chart-id') - // $.erp_framework.report_loader.displayChart(data, $this.parents('[data-report-widget]').find('[data-report-chart]'), chart_id) - // - // }); - - $.erp_framework.report_loader = { - cache: $.erp_framework.cache, + $('body').on('click', 'a[data-chart-id]', function (e) { + e.preventDefault(); + let $this = $(this); + let data = $.slick_reporting.cache[$this.attr('data-report-slug')] + let chart_id = $this.attr('data-chart-id') + $.slick_reporting.report_loader.displayChart(data, $this.parents('[data-report-widget]').find('[data-report-chart]'), chart_id) + + }); + + $.slick_reporting.report_loader = { + cache: $.slick_reporting.cache, initialize: initialize, refreshReportWidget: refreshReportWidget, failFunction: failFunction, diff --git a/slick_reporting/templates/slick_reporting/base.html b/slick_reporting/templates/slick_reporting/base.html index 5a65ee7..1193aa2 100644 --- a/slick_reporting/templates/slick_reporting/base.html +++ b/slick_reporting/templates/slick_reporting/base.html @@ -9,14 +9,20 @@ - - Django Slick Reporting + + + {{ report_title }} | Django Slick Reporting
+ +
+

{{ report_title }}

{% block content %} {% endblock %} diff --git a/slick_reporting/templates/slick_reporting/js_resources.html b/slick_reporting/templates/slick_reporting/js_resources.html index 1279cc2..7b200dc 100644 --- a/slick_reporting/templates/slick_reporting/js_resources.html +++ b/slick_reporting/templates/slick_reporting/js_resources.html @@ -1,20 +1,16 @@ {% load i18n static %} - - - -{##} -{##} + - - + + @@ -23,25 +19,17 @@ - - - - - - - - + + + + + diff --git a/slick_reporting/templates/slick_reporting/report.html b/slick_reporting/templates/slick_reporting/report.html new file mode 100644 index 0000000..8a11c0f --- /dev/null +++ b/slick_reporting/templates/slick_reporting/report.html @@ -0,0 +1,47 @@ +{% extends 'slick_reporting/base.html' %} +{% load crispy_forms_tags i18n slick_reporting_tags static %} + + +{% block content %} +
+ {% if form %} +
+
+

{% trans "Filters" %}

+
+
+ {% crispy form crispy_helper %} +
+ +
+ {% endif %} + +
+
+
{% trans "Results" %}
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+ +{% endblock %} diff --git a/slick_reporting/templates/slick_reporting/simple_report.html b/slick_reporting/templates/slick_reporting/simple_report.html deleted file mode 100644 index 74543c5..0000000 --- a/slick_reporting/templates/slick_reporting/simple_report.html +++ /dev/null @@ -1,129 +0,0 @@ -{% extends 'slick_reporting/base.html' %} -{% load crispy_forms_tags i18n slick_reporting_tags static %} - - -{% block content %} - - -
- -

- {{ title }} -

- {% if form %} - -

Filters

-
- {% crispy form crispy_helper %} - - -
- {% endif %} -

Results

-
-
-
- -
- {% if report_data.chart_settings %} - {# #} - {% if report_data.charts_engine == 'chartsjs' %} - - {% elif report_data.charts_engine == 'highcharts' %} - {#
#} - {% endif %} - - - {% endif %} -
-
- {% include 'slick_reporting/table.html' with table=report_data %} -
-
-
- -
- -{% endblock %} -{% block extrajs %} - {{ block.super }} - - - -{% endblock %} diff --git a/slick_reporting/templates/slick_reporting/widget_template.html b/slick_reporting/templates/slick_reporting/widget_template.html new file mode 100644 index 0000000..43dbfcb --- /dev/null +++ b/slick_reporting/templates/slick_reporting/widget_template.html @@ -0,0 +1,31 @@ +{% load slick_reporting_tags %} + + +
+ {% if display_title %} +
+
{{ title }}
+
+ {% endif %} +
+
+ {% block widget_content %} + {% if display_chart %} +
+ {% endif %} + {% if display_table %} +
+
+ {% endif %} + {% 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 8abf33c..a985e91 100644 --- a/slick_reporting/templatetags/slick_reporting_tags.py +++ b/slick_reporting/templatetags/slick_reporting_tags.py @@ -3,6 +3,8 @@ from django import template from django.core.serializers import serialize from django.db.models import QuerySet +from django.template.loader import get_template +from django.urls import reverse, resolve from django.utils.encoding import force_str from django.utils.functional import Promise from django.utils.safestring import mark_safe @@ -29,3 +31,44 @@ def date_handler(obj): register.filter("jsonify", jsonify) + + +@register.simple_tag +def get_widget_from_url(url_name=None, url=None, **kwargs): + _url = "" + if not (url_name or url): + raise ValueError("url_name or url must be provided") + if url_name: + url = reverse(url_name) + view = resolve(url) + kwargs["report"] = view.func.view_class + kwargs["report_url"] = url + return get_widget(**kwargs) + + +@register.simple_tag +def get_widget(report, template_name="", url_name="", report_url=None, **kwargs): + kwargs["report"] = report + if not report: + raise ValueError("report argument is empty. Are you sure you're using the correct report name") + if not (report_url or url_name): + raise ValueError("report_url or url_name must be provided") + + # if not report.chart_settings: + kwargs.setdefault("display_chart", bool(report.chart_settings)) + kwargs.setdefault("display_table", True) + + kwargs.setdefault("display_chart_selector", kwargs["display_chart"]) + kwargs.setdefault("display_title", True) + + passed_title = kwargs.get("title", None) + kwargs["title"] = passed_title or report.get_report_title() + kwargs["report_url"] = report_url + if not report_url: + kwargs["report_url"] = reverse(url_name) + + kwargs.setdefault("extra_params", "") + + template = get_template(template_name or "slick_reporting/widget_template.html") + + return template.render(context=kwargs) diff --git a/slick_reporting/views.py b/slick_reporting/views.py index 864bbb8..c1115b3 100644 --- a/slick_reporting/views.py +++ b/slick_reporting/views.py @@ -44,9 +44,7 @@ def get_filename(self): def get_response(self): response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = "attachment; filename={filename}.csv".format( - filename=self.get_filename() - ) + response["Content-Disposition"] = "attachment; filename={filename}.csv".format(filename=self.get_filename()) writer = csv.writer(response) for rows in self.get_rows(): @@ -61,9 +59,7 @@ def get_rows(self): yield [line[col_name] for col_name in columns] def get_columns(self, extra_context=None): - return list( - zip(*[(x["name"], x["verbose_name"]) for x in self.report_data["columns"]]) - ) + return list(zip(*[(x["name"], x["verbose_name"]) for x in self.report_data["columns"]])) def __init__(self, request, report_data, report_title, **kwargs): self.request = request @@ -85,9 +81,7 @@ def write(self, value): (writer.writerow(row) for row in self.get_rows()), content_type="text/csv", headers={ - "Content-Disposition": 'attachment; filename="{filename}.csv"'.format( - filename=self.get_filename() - ) + "Content-Disposition": 'attachment; filename="{filename}.csv"'.format(filename=self.get_filename()) }, ) @@ -97,7 +91,7 @@ class ReportViewBase(ReportGeneratorAPI, FormView): report_title = "" - report_title_context_key = "title" + report_title_context_key = "report_title" report_generator_class = ReportGenerator @@ -118,25 +112,34 @@ class ReportViewBase(ReportGeneratorAPI, FormView): doc_type_field_name = "doc_type" doc_type_plus_list = None doc_type_minus_list = None + auto_load = True default_order_by = "" - template_name = "slick_reporting/simple_report.html" + template_name = "slick_reporting/report.html" @staticmethod def form_filter_func(fkeys_dict): # todo revise return fkeys_dict + @classmethod + def get_report_title(cls): + """ + :return: The report name + """ + name = cls.__name__ + if cls.report_title: + name = cls.report_title + return name + def order_results(self, data): """ order the results based on GET parameter or default_order_by :param data: List of Dict to be ordered :return: Ordered data """ - order_field, asc = OrderByForm(self.request.GET).get_order_by( - self.default_order_by - ) + order_field, asc = OrderByForm(self.request.GET).get_order_by(self.default_order_by) if order_field: data = dictsort(data, order_field, asc) return data @@ -144,13 +147,9 @@ def order_results(self, data): def get_doc_types_q_filters(self): if self.doc_type_plus_list or self.doc_type_minus_list: return ( - [Q(**{f"{self.doc_type_field_name}__in": self.doc_type_plus_list})] - if self.doc_type_plus_list - else [] + [Q(**{f"{self.doc_type_field_name}__in": self.doc_type_plus_list})] if self.doc_type_plus_list else [] ), ( - [Q(**{f"{self.doc_type_field_name}__in": self.doc_type_minus_list})] - if self.doc_type_minus_list - else [] + [Q(**{f"{self.doc_type_field_name}__in": self.doc_type_minus_list})] if self.doc_type_minus_list else [] ) return [], [] @@ -158,31 +157,31 @@ def get_doc_types_q_filters(self): def get(self, request, *args, **kwargs): form_class = self.get_form_class() self.form = self.get_form(form_class) + report_data = {} if self.form.is_valid(): - report_data = self.get_report_results() - - export_option = request.GET.get("_export", "") - if export_option: - try: - return getattr(self, f"export_{export_option}")(report_data) - except AttributeError: - pass - - if request.headers.get("x-requested-with") == "XMLHttpRequest": - return self.ajax_render_to_response(report_data) - - return self.render_to_response( - self.get_context_data(report_data=report_data) - ) + if self.request.GET or self.request.POST or request.headers.get("x-requested-with") == "XMLHttpRequest": + # only display results if it's requested, + # considered requested if it's ajax request, or a populated GET or POST. + report_data = self.get_report_results() + + export_option = request.GET.get("_export", "") + if export_option: + try: + return getattr(self, f"export_{export_option}")(report_data) + except AttributeError: + pass + + if request.headers.get("x-requested-with") == "XMLHttpRequest": + return self.ajax_render_to_response(report_data) + + return self.render_to_response(self.get_context_data(report_data=report_data)) else: return self.form_invalid(self.form) # return self.render_to_response(self.get_context_data()) def export_csv(self, report_data): - return self.csv_export_class( - self.request, report_data, self.report_title - ).get_response() + return self.csv_export_class(self.request, report_data, self.report_title).get_response() @classmethod def get_report_model(cls): @@ -191,9 +190,7 @@ def get_report_model(cls): return cls.report_model def ajax_render_to_response(self, report_data): - return HttpResponse( - self.serialize_to_json(report_data), content_type="application/json" - ) + return HttpResponse(self.serialize_to_json(report_data), content_type="application/json") def serialize_to_json(self, response_data): """Returns the JSON string for the compiled data object.""" @@ -210,9 +207,7 @@ def date_handler(obj): if settings.DEBUG: indent = 4 - return json.dumps( - response_data, indent=indent, use_decimal=True, default=date_handler - ) + return json.dumps(response_data, indent=indent, use_decimal=True, default=date_handler) def get_form_class(self): """ @@ -257,11 +252,18 @@ def get_form_kwargs(self): ) return kwargs + def get_crosstab_ids(self): + """ + Hook to get the crosstab ids + :return: + """ + return self.form.get_crosstab_ids() + def get_report_generator(self, queryset, for_print): q_filters, kw_filters = self.form.get_filters() crosstab_compute_remainder = False if self.crosstab_field: - self.crosstab_ids = self.form.get_crosstab_ids() + self.crosstab_ids = self.get_crosstab_ids() try: crosstab_compute_remainder = ( self.form.get_crosstab_compute_remainder() @@ -302,6 +304,7 @@ def get_report_generator(self, queryset, for_print): crosstab_ids=self.crosstab_ids, crosstab_columns=self.crosstab_columns, crosstab_compute_remainder=crosstab_compute_remainder, + crosstab_ids_custom_filters=self.crosstab_ids_custom_filters, format_row_func=self.format_row, container_class=self, doc_type_plus_list=doc_type_plus_list, @@ -356,9 +359,7 @@ def get_chart_settings(self, generator): """ Ensure the sane settings are passed to the front end. """ - return generator.get_chart_settings( - self.chart_settings or [], self.report_title - ) + return generator.get_chart_settings(self.chart_settings or [], self.report_title) @classmethod def get_queryset(cls): @@ -401,6 +402,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context[self.report_title_context_key] = self.report_title context["crispy_helper"] = self.get_form_crispy_helper() + context["auto_load"] = self.auto_load 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 @@ -478,6 +480,8 @@ def get_report_generator(self, queryset, for_print): return self.report_generator_class( self.get_report_model(), + 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, diff --git a/tests/report_generators.py b/tests/report_generators.py index ba768f0..a3445e9 100644 --- a/tests/report_generators.py +++ b/tests/report_generators.py @@ -3,7 +3,7 @@ from django.db.models import Sum from django.utils.translation import gettext_lazy as _ -from slick_reporting.fields import SlickReportField, PercentageToBalance +from slick_reporting.fields import ComputationField, PercentageToBalance from slick_reporting.generator import ReportGenerator from .models import ( Client, @@ -45,7 +45,7 @@ class CrosstabOnClient(GenericGenerator): crosstab_field = "client" # crosstab_columns = ['__total_quantity__'] crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "quantity", name="value__sum", verbose_name=_("Sales") ) ] @@ -56,7 +56,7 @@ class CrosstabTimeSeries(GenericGenerator): columns = ["name", "__total_quantity__"] # crosstab_field = "client" # crosstab_columns = [ - # SlickReportField.create( + # ComputationField.create( # Sum, "quantity", name="value__sum", verbose_name=_("Sales") # ) # ] @@ -76,7 +76,7 @@ class CrosstabOnField(ReportGenerator): crosstab_ids = ["sales", "sales-return"] crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "quantity", name="value__sum", verbose_name=_("Sales") ) ] @@ -97,7 +97,7 @@ class CrosstabCustomQueryset(ReportGenerator): ] crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "quantity", name="value__sum", verbose_name=_("Sales") ) ] @@ -114,7 +114,7 @@ class CrosstabOnTraversingField(ReportGenerator): crosstab_ids = ["FEMALE", "MALE", "OTHER"] crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "quantity", name="value__sum", verbose_name=_("Sales") ) ] @@ -146,17 +146,17 @@ class GroupByCharField(ReportGenerator): report_model = SalesWithFlag date_field = "doc_date" group_by = "flag" - columns = ["flag", "__balance__", SlickReportField.create(Sum, "quantity")] + columns = ["flag", "__balance__", ComputationField.create(Sum, "quantity")] class GroupByCharFieldPlusTimeSeries(ReportGenerator): report_model = SalesWithFlag date_field = "doc_date" group_by = "flag" - columns = ["flag", SlickReportField.create(Sum, "quantity")] + columns = ["flag", ComputationField.create(Sum, "quantity")] time_series_pattern = "monthly" - time_series_columns = [SlickReportField.create(Sum, "quantity")] + time_series_columns = [ComputationField.create(Sum, "quantity")] class ClientTotalBalancesOrdered(ClientTotalBalance): @@ -388,7 +388,7 @@ class ProductClientSalesMatrix2(ReportGenerator): crosstab_field = "client" crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] @@ -403,7 +403,7 @@ class ProductClientSalesMatrixwSimpleSales2(ReportGenerator): crosstab_field = "client" crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] diff --git a/tests/settings.py b/tests/settings.py index e67ad6f..e57b833 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -29,6 +29,7 @@ "django.contrib.staticfiles", "slick_reporting", "crispy_forms", + "crispy_bootstrap4", "tests", ] diff --git a/tests/test_generator.py b/tests/test_generator.py index 6aade5b..051ef15 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -4,8 +4,8 @@ from django.db.models import Sum from django.test import TestCase -from slick_reporting.fields import SlickReportField -from slick_reporting.generator import ReportGenerator +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 _ @@ -18,7 +18,6 @@ TimeSeriesCustomDates, CrosstabOnField, CrosstabOnTraversingField, - CrosstabTimeSeries, CrosstabCustomQueryset, ) @@ -28,15 +27,11 @@ class CrosstabTests(BaseTestData, TestCase): def test_matrix_column_included(self): - report = CrosstabOnClient( - crosstab_ids=[self.client1.pk], crosstab_compute_remainder=False - ) + report = CrosstabOnClient(crosstab_ids=[self.client1.pk], crosstab_compute_remainder=False) columns = report.get_list_display_columns() self.assertEqual(len(columns), 3, columns) - report = CrosstabOnClient( - crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True - ) + report = CrosstabOnClient(crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True) columns = report.get_list_display_columns() self.assertEqual(len(columns), 4, columns) @@ -50,9 +45,7 @@ def test_matrix_column_position(self): self.assertEqual(len(columns), 3, columns) self.assertEqual(columns[0]["name"], "value__sumCT1") - report = CrosstabOnClient( - crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True - ) + report = CrosstabOnClient(crosstab_ids=[self.client1.pk], crosstab_compute_remainder=True) columns = report.get_list_display_columns() self.assertEqual(len(columns), 4, columns) @@ -73,9 +66,7 @@ def test_get_crosstab_parsed_columns(self): Test important attributes are passed . :return: """ - report = CrosstabOnClient( - crosstab_ids=[self.client1.pk], crosstab_compute_remainder=False - ) + report = CrosstabOnClient(crosstab_ids=[self.client1.pk], crosstab_compute_remainder=False) columns = report.get_crosstab_parsed_columns() for col in columns: self.assertTrue("is_summable" in col.keys(), col) @@ -115,19 +106,13 @@ def test_crosstab_time_series(self): columns=["name", "__total_quantity__"], time_series_pattern="monthly", crosstab_field="client", - crosstab_columns=[ - SlickReportField.create( - Sum, "quantity", name="value__sum", verbose_name=_("Sales") - ) - ], + crosstab_columns=[ComputationField.create(Sum, "quantity", name="value__sum", verbose_name=_("Sales"))], crosstab_ids=[self.client2.pk, self.client3.pk], crosstab_compute_remainder=False, ) columns = report.get_list_display_columns() time_series_columns = report.get_time_series_parsed_columns() - expected_num_of_columns = ( - 2 * datetime.today().month - ) # 2 client + 1 remainder * months since start of year + expected_num_of_columns = 2 * datetime.today().month # 2 client + 1 remainder * months since start of year self.assertEqual(len(time_series_columns), expected_num_of_columns, columns) data = report.get_report_data() @@ -181,34 +166,20 @@ def test_time_series_patterns(self): dates = report._get_time_series_dates() self.assertEqual(len(dates), 12) - self.assertIsNotNone( - report.get_time_series_field_verbose_name( - TotalReportField, dates[0], 0, dates - ) - ) + self.assertIsNotNone(report.get_time_series_field_verbose_name(TotalReportField, dates[0], 0, dates)) dates = report._get_time_series_dates("daily") self.assertEqual(len(dates), 365, len(dates)) - self.assertIsNotNone( - report.get_time_series_field_verbose_name( - TotalReportField, dates[0], 0, dates, "daily" - ) - ) + self.assertIsNotNone(report.get_time_series_field_verbose_name(TotalReportField, dates[0], 0, dates, "daily")) dates = report._get_time_series_dates("weekly") self.assertEqual(len(dates), 53, len(dates)) - self.assertIsNotNone( - report.get_time_series_field_verbose_name( - TotalReportField, dates[0], 0, dates, "weekly" - ) - ) + self.assertIsNotNone(report.get_time_series_field_verbose_name(TotalReportField, dates[0], 0, dates, "weekly")) - dates = report._get_time_series_dates("semimonthly") + dates = report._get_time_series_dates("bi-weekly") self.assertEqual(len(dates), 27, len(dates)) self.assertIsNotNone( - report.get_time_series_field_verbose_name( - TotalReportField, dates[0], 0, dates, "semimonthly" - ) + report.get_time_series_field_verbose_name(TotalReportField, dates[0], 0, dates, "semimonthly") ) dates = report._get_time_series_dates("quarterly") @@ -218,11 +189,7 @@ def test_time_series_patterns(self): self.assertEqual(len(dates), 2, len(dates)) dates = report._get_time_series_dates("annually") self.assertEqual(len(dates), 1, len(dates)) - self.assertIsNotNone( - report.get_time_series_field_verbose_name( - TotalReportField, dates[0], 0, dates - ) - ) + self.assertIsNotNone(report.get_time_series_field_verbose_name(TotalReportField, dates[0], 0, dates)) def not_known_pattern(): report._get_time_series_dates("each_spring") @@ -263,17 +230,13 @@ def test_attr_as_column(self): def test_improper_group_by(self): def load(): - ReportGenerator( - OrderLine, group_by="no_field", date_field="order__date_placed" - ) + ReportGenerator(OrderLine, group_by="no_field", date_field="order__date_placed") self.assertRaises(Exception, load) def test_missing_report_model(self): def load(): - ReportGenerator( - report_model=None, group_by="product", date_field="order__date_placed" - ) + ReportGenerator(report_model=None, group_by="product", date_field="order__date_placed") self.assertRaises(Exception, load) @@ -285,9 +248,7 @@ def load(): def test_wrong_date_field(self): def load(): - ReportGenerator( - report_model=OrderLine, group_by="product", date_field="not_here" - ) + ReportGenerator(report_model=OrderLine, group_by="product", date_field="not_here") self.assertRaises(Exception, load) @@ -320,7 +281,7 @@ def test_group_by_traverse(self): group_by="product__category", columns=[ "product__category", - SlickReportField.create(Sum, "value"), + ComputationField.create(Sum, "value"), "__total__", ], # time_series_pattern='monthly', @@ -342,7 +303,7 @@ def test_group_by_and_foreign_key_field(self): "name", "contact_id", "contact__address", - SlickReportField.create(Sum, "value"), + ComputationField.create(Sum, "value"), "__total__", ], # time_series_pattern='monthly', @@ -374,15 +335,13 @@ def test_custom_group_by(self): report = ReportGenerator( report_model=SimpleSales, group_by_custom_querysets=[ - SimpleSales.objects.filter( - client_id__in=[self.client1.pk, self.client2.pk] - ), + SimpleSales.objects.filter(client_id__in=[self.client1.pk, self.client2.pk]), SimpleSales.objects.filter(client_id__in=[self.client3.pk]), ], group_by_custom_querysets_column_verbose_name="Custom Title", columns=[ # "__index__", is added automatically - SlickReportField.create(Sum, "value"), + ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", @@ -395,20 +354,16 @@ def test_custom_group_by(self): columns_data = report.get_columns_data() self.assertEqual(columns_data[0]["verbose_name"], "Custom Title") - def test_custom_group_by_with_index(self): report = ReportGenerator( report_model=SimpleSales, group_by_custom_querysets=[ - SimpleSales.objects.filter( - client_id__in=[self.client1.pk, self.client2.pk] - ), + SimpleSales.objects.filter(client_id__in=[self.client1.pk, self.client2.pk]), SimpleSales.objects.filter(client_id__in=[self.client3.pk]), ], columns=[ "__index__", # assert that no issue if added manually , issue 68 - - SlickReportField.create(Sum, "value"), + ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", @@ -428,7 +383,7 @@ def test_traversing_group_by_and_foreign_key_field(self): "po_box", "address", "agent__name", - SlickReportField.create(Sum, "value"), + ComputationField.create(Sum, "value"), "__total__", ], date_field="doc_date", @@ -447,7 +402,7 @@ def test_traversing_group_by_sanity(self): report = ReportGenerator( report_model=SimpleSales, group_by="client__contact__agent", - columns=["name", SlickReportField.create(Sum, "value"), "__total__"], + columns=["name", ComputationField.create(Sum, "value"), "__total__"], date_field="doc_date", ) @@ -479,3 +434,16 @@ class TestHelpers(TestCase): def test_get_model_for_keys(self): keys = get_foreign_keys(OrderLine) self.assertEqual(len(keys), 3) + + +class TestListViewGenerator(BaseTestData, TestCase): + def test_traversing_field_in_column(self): + report = ListViewReportGenerator( + report_model=SimpleSales, + columns=["id", "product__name", "client__name", "value"], + date_field="doc_date", + ) + data = report.get_report_data() + self.assertEqual(len(data), SimpleSales.objects.count()) + self.assertEqual(data[0]["product__name"], "Product 1") + self.assertEqual(data[0]["client__name"], "Client 1") diff --git a/tests/tests.py b/tests/tests.py index 1a5722a..cb086b9 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.timezone import now -from slick_reporting.fields import SlickReportField, BalanceReportField +from slick_reporting.fields import ComputationField, BalanceReportField from slick_reporting.generator import ReportGenerator from slick_reporting.views import ReportView from slick_reporting.registry import field_registry @@ -68,22 +68,12 @@ def setUpTestData(cls): cls.client3.save() cls.clientIdle = Client.objects.create(name="Client Idle") - cls.product1 = Product.objects.create( - name="Product 1", category="small", sku="a1b1" - ) - cls.product2 = Product.objects.create( - name="Product 2", category="medium", sku="a2b2" - ) - cls.product3 = Product.objects.create( - name="Product 3", category="big", sku="3333" - ) + cls.product1 = Product.objects.create(name="Product 1", category="small", sku="a1b1") + cls.product2 = Product.objects.create(name="Product 2", category="medium", sku="a2b2") + cls.product3 = Product.objects.create(name="Product 3", category="big", sku="3333") - cls.product_w_custom_id1 = ProductCustomID.objects.create( - name="Product 1", category="small" - ) - cls.product_w_custom_id2 = ProductCustomID.objects.create( - name="Product 2", category="medium" - ) + cls.product_w_custom_id1 = ProductCustomID.objects.create(name="Product 1", category="small") + cls.product_w_custom_id2 = ProductCustomID.objects.create(name="Product 2", category="medium") SimpleSales.objects.create( doc_date=datetime.datetime(year, 1, 2), @@ -384,21 +374,8 @@ def test_client_client_sales_monthly(self): # todo add __fb__ to time series and check the balance - def test_client_statement_detail(self): - """ - Test the detail statement - This is do pass by making a document slug clickable ( elem) - and it also passes by the slug search of the model admin - :return: - """ - report = report_generators.ClientDetailedStatement() - data = report.get_report_data() - self.assertEqual(len(data), 9) - def test_productclientsalesmatrix(self): - report = report_generators.ProductClientSalesMatrix( - crosstab_ids=[self.client1.pk, self.client2.pk] - ) + report = report_generators.ProductClientSalesMatrix(crosstab_ids=[self.client1.pk, self.client2.pk]) data = report.get_report_data() self.assertEqual(data[0]["__total__CT%s" % self.client1.pk], 300) self.assertEqual(data[0]["__total__CT%s" % self.client2.pk], 600) @@ -422,15 +399,11 @@ def test_show_empty_records(self): # self.assertEqual(data[0].get('__balance__'), 300, data[0]) def test_filters(self): - report = ClientTotalBalance( - kwargs_filters={"client": self.client1.pk}, show_empty_records=True - ) + report = ClientTotalBalance(kwargs_filters={"client": self.client1.pk}, show_empty_records=True) data = report.get_report_data() self.assertEqual(len(data), 1, data) - report = ClientTotalBalance( - kwargs_filters={"client": self.client1.pk}, show_empty_records=False - ) + report = ClientTotalBalance(kwargs_filters={"client": self.client1.pk}, show_empty_records=False) data = report.get_report_data() self.assertEqual(len(data), 1, data) @@ -458,15 +431,11 @@ def test_view_filter_to_field_set(self): # self.assertEqual(view_report_data['data'], data) def test_filter_as_int_n_list(self): - report = ClientTotalBalance( - kwargs_filters={"client": self.client1.pk}, show_empty_records=True - ) + report = ClientTotalBalance(kwargs_filters={"client": self.client1.pk}, show_empty_records=True) data = report.get_report_data() self.assertEqual(len(data), 1, data) - report = ClientTotalBalance( - kwargs_filters={"client_id__in": [self.client1.pk]}, show_empty_records=True - ) + report = ClientTotalBalance(kwargs_filters={"client_id__in": [self.client1.pk]}, show_empty_records=True) data = report.get_report_data() self.assertEqual(len(data), 1, data) @@ -476,9 +445,7 @@ def test_timeseries_without_group(self): self.assertEqual(data[0][f"__total__TS{year}0201"], 600) def test_many_to_many_group_by(self): - field_registry.register( - SlickReportField.create(Count, "tax__name", "tax__count") - ) + field_registry.register(ComputationField.create(Count, "tax__name", "tax__count")) report_generator = ReportGenerator( report_model=ComplexSales, @@ -499,9 +466,12 @@ def test_many_to_many_group_by(self): class TestView(BaseTestData, TestCase): def test_view(self): - response = self.client.get(reverse("report1")) + response = self.client.get( + reverse("report1"), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) self.assertEqual(response.status_code, 200) - view_report_data = response.context["report_data"]["data"] + view_report_data = response.json()["data"] report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", @@ -514,9 +484,12 @@ def test_view(self): self.assertEqual(view_report_data, report_generator.get_report_data()) def test_qs_only(self): - response = self.client.get(reverse("queryset-only")) + response = self.client.get( + reverse("queryset-only"), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) self.assertEqual(response.status_code, 200) - view_report_data = response.context["report_data"]["data"] + view_report_data = response.json()["data"] report_generator = ReportGenerator( report_model=SimpleSales, date_field="doc_date", @@ -583,9 +556,7 @@ def test_ajax(self): time_series_columns=["__total__", "__balance__"], ) data = report_generator.get_report_data() - response = self.client.get( - reverse("report1"), HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) + response = self.client.get(reverse("report1"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(response.status_code, 200) view_report_data = response.json() self.assertEqual(view_report_data["data"], data) @@ -703,7 +674,7 @@ def test_unregister(self): def test_registering_new(self): def register(): - class ReportFieldWDuplicatedName(SlickReportField): + class ReportFieldWDuplicatedName(ComputationField): name = "__total_field__" calculation_field = "field" @@ -714,7 +685,7 @@ class ReportFieldWDuplicatedName(SlickReportField): def test_already_registered(self): def register(): - class ReportFieldWDuplicatedName(SlickReportField): + class ReportFieldWDuplicatedName(ComputationField): name = "__total__" field_registry.register(ReportFieldWDuplicatedName) @@ -739,13 +710,13 @@ def register(): def test_creating_a_report_field_on_the_fly(self): from django.db.models import Sum - name = SlickReportField.create(Sum, "value", "__sum_of_value__") + name = ComputationField.create(Sum, "value", "__sum_of_value__") self.assertNotIn(name, field_registry.get_all_report_fields_names()) def test_creating_a_report_field_on_the_fly_wo_name(self): from django.db.models import Sum - name = SlickReportField.create(Sum, "value") + name = ComputationField.create(Sum, "value") self.assertNotIn(name, field_registry.get_all_report_fields_names()) @@ -753,19 +724,13 @@ class TestGroupByDate(TestCase): @classmethod def setUpTestData(cls): super().setUpTestData() - UserJoined.objects.create( - username="adam", date_joined=datetime.date(2020, 1, 2) - ) + UserJoined.objects.create(username="adam", date_joined=datetime.date(2020, 1, 2)) UserJoined.objects.create(username="eve", date_joined=datetime.date(2020, 1, 3)) - UserJoined.objects.create( - username="steve", date_joined=datetime.date(2020, 1, 5) - ) - UserJoined.objects.create( - username="smiv", date_joined=datetime.date(2020, 1, 5) - ) + UserJoined.objects.create(username="steve", date_joined=datetime.date(2020, 1, 5)) + UserJoined.objects.create(username="smiv", date_joined=datetime.date(2020, 1, 5)) def test_joined_per_day(self): - field_registry.register(SlickReportField.create(Count, "id", "count__id")) + field_registry.register(ComputationField.create(Count, "id", "count__id")) report_generator = ReportGenerator( report_model=UserJoined, date_field="date_joined", diff --git a/tests/views.py b/tests/views.py index acff12d..0809dfe 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,5 +1,5 @@ from slick_reporting.views import ReportView -from slick_reporting.fields import SlickReportField, TotalReportField +from slick_reporting.fields import ComputationField, TotalReportField from django.db.models import Sum, Count from .models import SimpleSales, ComplexSales, SimpleSales2 from django.utils.translation import gettext_lazy as _ @@ -73,7 +73,7 @@ class CrossTabColumnOnFly(ReportView): crosstab_field = "client" crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] @@ -97,7 +97,7 @@ class CrossTabColumnOnFlyToFieldSet(ReportView): crosstab_field = "client" crosstab_columns = [ - SlickReportField.create( + ComputationField.create( Sum, "value", name="value__sum", verbose_name=_("Sales") ) ] @@ -127,7 +127,7 @@ class TaxSales(ReportView): group_by = "tax__name" columns = [ "tax__name", - SlickReportField.create( + ComputationField.create( Count, "tax", name="tax__count", verbose_name=_("Sales") ), ] @@ -156,7 +156,7 @@ class TaxSales(ReportView): group_by = "tax__name" columns = [ "tax__name", - SlickReportField.create( + ComputationField.create( Count, "tax", name="tax__count", verbose_name=_("Sales") ), ]